merge(dev): sync dev into main

This commit is contained in:
777genius 2026-05-06 00:09:44 +03:00
commit 70494e2e40
85 changed files with 4785 additions and 853 deletions

View file

@ -170,27 +170,31 @@ For feature architecture and implementation guidance:
## Comparison ## Comparison
| Feature | Agent Teams | Vibe Kanban | Aperant | Cursor | Claude Code CLI | | Feature | Agent Teams | Gastown | Paperclip | Cursor | Claude Code CLI |
|---|---|---|---|---|---| |---|---|---|---|---|---|
| **Cross-team communication** | ✅ | ❌ | ❌ | — | ❌ | | **Cross-team communication** | ✅ Native cross-team messages | ⚠️ Cross-rig coordination | ⚠️ Company-scoped org work | N/A | ❌ |
| **Agent-to-agent messaging** | ✅ Native real-time mailbox | ❌ Agents are independent | ❌ Fixed pipeline | ❌ | ✅⚠️ Built-in (no UI) | | **Agent-to-agent messaging** | ✅ Native real-time mailbox | ✅ Mailboxes + handoffs | ⚠️ Comments + @mentions | ❌ | ✅ Team mailbox, no UI |
| **Linked tasks** | ✅ Cross-references in messages | ⚠️ Subtasks only | ❌ | ❌ | ❌ | | **Linked tasks** | ✅ Cross-refs + dependencies | ⚠️ Beads deps + convoys | ✅ Goals, parents, blockers | ❌ | ✅ Shared task list |
| **Session analysis** | ✅ 6-category token tracking | ❌ | ⚠️ Execution logs | ❌ | ❌ | | **Session analysis** | ✅ Task logs + token tracking | ⚠️ Session recall, feed, OTEL | ⚠️ Run transcripts + cost audit | ❌ | ⚠️ Usage command, no UI |
| **Task attachments** | ✅ Auto-attach, agents read & attach files | ❌ | ✅ Images + files | ⚠️ Chat session only | ❌ | | **Task attachments** | ✅ Auto-attach, agents read & attach files | ❌ Not task-level | ✅ Docs, attachments, work products | ⚠️ Chat session only | ⚠️ Chat images only |
| **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ | ✅ | ❌ | | **Hunk-level review** | ✅ Accept / reject individual hunks | ❌ | ❌ Bring your own review | ✅ | ❌ |
| **Built-in code editor** | ✅ With Git support | ❌ | ❌ | ✅ Full IDE | ❌ | | **Built-in code editor** | ✅ With Git support | ❌ | ❌ Control plane, not editor | ✅ Full IDE | ❌ |
| **Full autonomy** | ✅ Agents create, assign, review tasks end-to-end | ❌ Human manages tasks | ❌ Fixed pipeline | ⚠️ Isolated tasks only | ✅⚠️ (no UI) | | **Full autonomy** | ✅ Agents create, assign, review tasks end-to-end | ✅ Mayor, convoys, recovery | ✅ Heartbeats + governance | ⚠️ Background agents, not teams | ✅ Experimental CLI teams |
| **Task dependencies (blocked by)** | ✅ Guaranteed ordering | ❌ | ⚠️ Within plan only | ❌ | ✅⚠️ (no UI, no notifications) | | **Task dependencies (blocked by)** | ✅ Guaranteed ordering | ✅ DAG waves via Beads | ✅ Blockers + execution locks | ❌ | ✅ Team task deps, no UI |
| **Review workflow** | ✅ Agents review each other | ❌ | ⚠️ Auto QA pipeline | ❌ | ✅⚠️ (no UI) | | **Review workflow** | ✅ Agents review each other + human review UI | ⚠️ Refinery merge queue | ✅ Approvals + governance | ⚠️ PR/BugBot only | ✅ Team review, no UI |
| **Zero setup** | ✅ | ❌ Config required | ❌ Config required | ✅ | ⚠️ CLI install required | | **Zero setup** | ✅ Guided runtime setup | ❌ Go/Git/Dolt/Beads/tmux | ⚠️ `npx` + embedded Postgres | ✅ | ⚠️ CLI + env flag |
| **Kanban board** | ✅ 5 columns, real-time | ✅ | ✅ 6 columns (pipeline) | ❌ | ❌ | | **Kanban board** | ✅ 5 columns, real-time | ❌ Dashboard, not Kanban | ✅ 7 columns, drag-and-drop | ❌ | ❌ |
| **Execution log viewer** | ✅ Tool calls, reasoning, timeline | ❌ | ✅ Phase-based logs | ✅ | ❌ | | **Execution log viewer** | ✅ Tool calls, reasoning, timeline | ⚠️ Feed, OTEL, dashboard | ✅ Run transcripts + ledger | ⚠️ Agent chat + terminal | ❌ |
| **Live processes** | ✅ View, stop, open URLs in browser | ❌ | ❌ | ✅ | ❌ | | **Live processes** | ✅ View, stop, open URLs in browser | ⚠️ Agent health dashboard | ⚠️ Manual services + previews | ⚠️ Native terminal only | ❌ |
| **Per-task code review** | ✅ Accept / reject / comment | ⚠️ PR-level only | ⚠️ File-level only | ✅ BugBot on PRs | ❌ | | **Per-task code review** | ✅ Accept / reject / comment | ⚠️ Merge queue, no diff UI | ⚠️ PR/work products, no inline diff | ✅ BugBot on PRs | ❌ |
| **Flexible autonomy** | ✅ Granular settings, per-action approval, notifications | ❌ | ⚠️ Plan approval only | ✅ | ✅ | | **Flexible autonomy** | ✅ Per-action approvals + notifications | ✅ Gates, escalation, recovery | ✅ Board approvals, pause, terminate | ⚠️ BG agents auto-run commands | ✅ Permissions + hooks |
| **Git worktree isolation** | ✅ Optional | ⚠️ Mandatory | ⚠️ Mandatory | ✅ | ✅ | | **Git worktree isolation** | ✅ Optional | ✅ Core primitive | ✅ Worktrees / branches | ⚠️ Background branches/VMs | ⚠️ Manual worktrees |
| **Multi-agent backend** | ✅ Codex, Claude, and 75+ providers | ✅ 6+ agents | ✅ 11 providers | ✅ Multi-model | — | | **Multi-agent backend** | ✅ Claude, Codex + OpenCode teammates | ✅ Claude, Codex, Gemini, Copilot + more | ✅ BYO agents: Claude, Codex, Cursor/OpenCode, HTTP | ⚠️ Multi-model agents, no team backend | ⚠️ Claude-only experimental teams |
| **Price** | **Free** | Free / $30 user/mo | Free | $0$200/mo | Provider subscription | | **Org chart / governance** | ⚠️ Roles + approvals, no org chart | ⚠️ Roles + escalation | ✅ Org chart + board governance | ⚠️ Team admin only | ❌ |
| **Budget controls** | ⚠️ Cost/token visibility, no hard caps | ⚠️ Cost tiers + digest, no hard caps | ✅ Per-agent budgets + hard stops | ⚠️ Usage + BG spend limits | ⚠️ `/cost` + workspace limits |
| **Price** | **Free OSS UI**, provider access needed | Free OSS, runtime plans needed | Free OSS, self-hosted + infra | Free + paid usage | Claude plan or API usage |
Fact sources checked on May 5, 2026: [detailed research notes](docs/research/gastown-paperclip-comparison-2026-05-05.md), [Gastown README](https://github.com/gastownhall/gastown), [Gastown provider guide](https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md), [Gastown scheduler](https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md), [Paperclip README](https://github.com/paperclipai/paperclip), [Paperclip adapters](https://github.com/paperclipai/paperclip/blob/master/docs/adapters/overview.md), [Paperclip budgets](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md), [Paperclip runtime services](https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md), [Paperclip Kanban source](https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx), [Cursor Background Agents](https://docs.cursor.com/en/background-agents), [Cursor Diffs & Review](https://docs.cursor.com/en/agent/review), [Cursor Bugbot](https://docs.cursor.com/en/bugbot), [Cursor pricing](https://docs.cursor.com/en/account/usage), [Claude Code agent teams](https://code.claude.com/docs/en/agent-teams), [Claude Code subagents](https://code.claude.com/docs/en/sub-agents), [Claude Code workflows](https://code.claude.com/docs/en/common-workflows), [Claude Code costs](https://code.claude.com/docs/en/costs), [Claude pricing](https://claude.com/pricing).
--- ---

View file

@ -253,11 +253,13 @@ function formatAllowedTaskCommentAuthors(paths, explicitMembers, options = {}) {
} }
} }
const leadName = inferLeadName(paths); if (options.allowLeadAliases !== false) {
const leadKey = normalizeMemberKey(leadName); const leadName = inferLeadName(paths);
if (leadKey && explicitMembers.membersByKey.has(leadKey)) { const leadKey = normalizeMemberKey(leadName);
allowed.add('lead'); if (leadKey && explicitMembers.membersByKey.has(leadKey)) {
allowed.add('team-lead'); allowed.add('lead');
allowed.add('team-lead');
}
} }
return Array.from(allowed).sort((a, b) => a.localeCompare(b)).join(', '); return Array.from(allowed).sort((a, b) => a.localeCompare(b)).join(', ');
@ -285,14 +287,18 @@ function resolveTaskCommentAuthorName(paths, candidate, label = 'task comment au
return directMember.name; return directMember.name;
} }
const leadAlias = resolveExplicitTeamMemberName(paths, normalized, { allowLeadAliases: true }); const leadAlias = resolveExplicitTeamMemberName(paths, normalized, {
allowLeadAliases: options.allowLeadAliases !== false,
});
if (leadAlias) { if (leadAlias) {
return leadAlias; return leadAlias;
} }
const { leadName, keys } = getLeadProviderKeys(paths, explicit); if (options.allowProviderAliases !== false) {
if (leadName && keys.has(key)) { const { leadName, keys } = getLeadProviderKeys(paths, explicit);
return leadName; if (leadName && keys.has(key)) {
return leadName;
}
} }
throw new Error( throw new Error(

View file

@ -518,7 +518,11 @@ function addTaskCommentWithOptions(context, taskId, flags, options = {}) {
context.paths, context.paths,
commentFlags.from, commentFlags.from,
'task comment author', 'task comment author',
{ allowReservedAuthors: !fromRequiredForAgentTool } {
allowReservedAuthors: !fromRequiredForAgentTool,
allowLeadAliases: !fromRequiredForAgentTool,
allowProviderAliases: !fromRequiredForAgentTool,
}
); );
const result = withTeamBoardLock(context.paths, () => const result = withTeamBoardLock(context.paths, () =>
taskStore.addTaskComment(context.paths, taskId, commentFlags.text, { taskStore.addTaskComment(context.paths, taskId, commentFlags.text, {

View file

@ -94,14 +94,16 @@ async function requestJsonWithFallback(baseUrls, pathname, options = {}) {
} }
function compactReportBody(context, memberName, flags = {}) { function compactReportBody(context, memberName, flags = {}) {
const taskIds = normalizeTaskIds(
Array.isArray(flags['task-ids']) ? flags['task-ids'] : flags.taskIds
);
return { return {
teamName: context.teamName, teamName: context.teamName,
memberName, memberName,
state: flags.state, state: flags.state,
agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'], agendaFingerprint: flags.agendaFingerprint || flags['agenda-fingerprint'],
reportToken: flags.reportToken || flags['report-token'], reportToken: flags.reportToken || flags['report-token'],
...(Array.isArray(flags.taskIds) ? { taskIds: flags.taskIds } : {}), ...(taskIds.length > 0 ? { taskIds } : {}),
...(Array.isArray(flags['task-ids']) ? { taskIds: flags['task-ids'] } : {}),
...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}), ...(typeof flags.note === 'string' && flags.note.trim() ? { note: flags.note.trim() } : {}),
...(typeof flags.reportedAt === 'string' && flags.reportedAt.trim() ...(typeof flags.reportedAt === 'string' && flags.reportedAt.trim()
? { reportedAt: flags.reportedAt.trim() } ? { reportedAt: flags.reportedAt.trim() }
@ -110,6 +112,12 @@ function compactReportBody(context, memberName, flags = {}) {
}; };
} }
function normalizeTaskIds(value) {
return Array.isArray(value)
? Array.from(new Set(value.map((taskId) => String(taskId).trim()).filter(Boolean)))
: [];
}
function stableStringify(value) { function stableStringify(value) {
if (value == null || typeof value !== 'object') { if (value == null || typeof value !== 'object') {
return JSON.stringify(value); return JSON.stringify(value);
@ -125,7 +133,9 @@ function stableStringify(value) {
function buildPendingIntentId(body) { function buildPendingIntentId(body) {
const taskIds = Array.isArray(body.taskIds) const taskIds = Array.isArray(body.taskIds)
? Array.from(new Set(body.taskIds.map((taskId) => String(taskId)).filter(Boolean))).sort() ? Array.from(
new Set(body.taskIds.map((taskId) => String(taskId).trim()).filter(Boolean))
).sort()
: []; : [];
const payload = { const payload = {
teamName: body.teamName, teamName: body.teamName,
@ -229,8 +239,12 @@ async function memberWorkSyncStatus(context, flags = {}) {
baseUrls, baseUrls,
`/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/${encodeURIComponent( `/api/teams/${encodeURIComponent(context.teamName)}/member-work-sync/${encodeURIComponent(
memberName memberName
)}`, )}/refresh`,
{ timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']) } {
method: 'POST',
body: {},
timeoutMs: normalizeTimeoutMs(flags.waitTimeoutMs || flags['wait-timeout-ms']),
}
); );
} }

View file

@ -1228,6 +1228,67 @@ describe('agent-teams-controller API', () => {
expect(fromSystem.comment.author).toBe('system'); expect(fromSystem.comment.author).toBe('system');
}); });
it('rejects provider and lead aliases for agent-facing task comments', () => {
const claudeDir = makeClaudeDir();
fs.writeFileSync(
path.join(claudeDir, 'teams', 'my-team', 'config.json'),
JSON.stringify(
{
name: 'my-team',
leadSessionId: 'lead-session-1',
members: [
{ name: 'team-lead', role: 'team-lead', providerId: 'codex', provider: 'codex' },
{ name: 'bob', role: 'developer' },
],
},
null,
2
)
);
const appController = createController({ teamName: 'my-team', claudeDir });
const agentController = createController({
teamName: 'my-team',
claudeDir,
allowUserMessageSender: false,
});
const task = appController.tasks.createTask({
subject: 'Reject agent aliases',
owner: 'bob',
notifyOwner: false,
});
expect(() =>
agentController.tasks.addTaskComment(task.id, {
from: 'codex',
text: 'Provider alias should not be accepted from MCP.',
})
).toThrow('Unknown task comment author: codex');
expect(() =>
agentController.tasks.addTaskComment(task.id, {
from: 'lead',
text: 'Lead alias should not be accepted from MCP.',
})
).toThrow('Unknown task comment author: lead');
let unknownAuthorError;
try {
agentController.tasks.addTaskComment(task.id, {
from: 'Codex',
text: 'Provider alias case should not be accepted from MCP.',
});
} catch (error) {
unknownAuthorError = error;
}
expect(unknownAuthorError.message).toContain('Unknown task comment author: Codex');
expect(unknownAuthorError.message).toContain('Use one of: bob, team-lead');
expect(unknownAuthorError.message).not.toContain('user');
expect(unknownAuthorError.message).not.toContain('system');
expect(appController.tasks.getTask(task.id).comments || []).toEqual([]);
});
it('does not map a real teammate named like the lead provider id to the lead', () => { it('does not map a real teammate named like the lead provider id to the lead', () => {
const claudeDir = makeClaudeDir(); const claudeDir = makeClaudeDir();
fs.writeFileSync( fs.writeFileSync(
@ -1255,6 +1316,18 @@ describe('agent-teams-controller API', () => {
}); });
expect(commented.comment.author).toBe('codex'); expect(commented.comment.author).toBe('codex');
const agentController = createController({
teamName: 'my-team',
claudeDir,
allowUserMessageSender: false,
});
const agentCommented = agentController.tasks.addTaskComment(task.id, {
from: 'codex',
text: 'Agent-facing real teammate comment.',
});
expect(agentCommented.comment.author).toBe('codex');
}); });
it('rejects task comments from unknown authors', () => { it('rejects task comments from unknown authors', () => {
@ -2445,7 +2518,7 @@ describe('agent-teams-controller API', () => {
const server = await startControlServer(async ({ method, url, body }) => { const server = await startControlServer(async ({ method, url, body }) => {
calls.push({ method, url, body }); calls.push({ method, url, body });
if (method === 'GET' && url === '/api/teams/my-team/member-work-sync/bob') { if (method === 'POST' && url === '/api/teams/my-team/member-work-sync/bob/refresh') {
return { return {
body: { body: {
teamName: 'my-team', teamName: 'my-team',
@ -2483,7 +2556,7 @@ describe('agent-teams-controller API', () => {
state: 'still_working', state: 'still_working',
agendaFingerprint: 'agenda:v1:abc', agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token', reportToken: 'wrs:v1.test.token',
taskIds: ['task-1'], taskIds: [' task-1 ', '', 'task-1'],
note: 'Continuing work', note: 'Continuing work',
leaseTtlMs: 120000, leaseTtlMs: 120000,
}); });
@ -2492,9 +2565,9 @@ describe('agent-teams-controller API', () => {
expect(report.accepted).toBe(true); expect(report.accepted).toBe(true);
expect(calls).toEqual([ expect(calls).toEqual([
{ {
method: 'GET', method: 'POST',
url: '/api/teams/my-team/member-work-sync/bob', url: '/api/teams/my-team/member-work-sync/bob/refresh',
body: undefined, body: {},
}, },
{ {
method: 'POST', method: 'POST',

View file

@ -0,0 +1,162 @@
# Gastown и Paperclip comparison для лендинга и README
> Дата проверки: 2026-05-05
> Цель: публичная таблица `Agent Teams | Gastown | Paperclip | Cursor | Claude Code CLI` без угадываний по конкурентам.
> Метод: первичные источники, `gh repo view`, official README/docs/releases, локальные клоны `gastownhall/gastown` и `paperclipai/paperclip`.
## Snapshot
| Проект | Позиционирование | Статус на 2026-05-05 | Лицензия |
|---|---|---:|---|
| **Gastown** | multi-agent workspace manager для coding agents | `14,962★`, latest `v1.0.1` от `2026-04-25`, push `2026-05-03` | MIT |
| **Paperclip** | control plane для autonomous AI companies | `62,668★`, latest `v2026.428.0` от `2026-04-28`, push `2026-05-05` | MIT |
## Что важно для публичного сравнения
### Gastown
Сильные факты:
- README позиционирует Gastown как workspace manager для Claude Code, GitHub Copilot, Codex, Gemini и других AI agents.
- Есть built-in mailboxes, identities, handoffs, Beads ledger, git-backed hooks, convoys, Witness/Deacon watchdog, Refinery merge queue.
- Dependencies сильнее, чем просто "convoys": convoy stage/launch строит DAG, считает waves, запускает Wave 1, а последующие waves daemon dispatches автоматически.
- Есть `gt feed`, web dashboard, OpenTelemetry events/metrics, `gt costs record`, cost tier presets, daily cost digest и context-budget guard.
- Agent provider story шире README: provider guide прямо перечисляет Claude, Gemini, Codex, Cursor, AMP, OpenCode, Copilot и loose-coupled tmux integration tiers.
- Scheduler сильнее простого queue: `scheduler.max_polecats` включает deferred dispatch, capacity governor, pause/resume, queued sling context beads и daemon dispatch cycle.
Ограничения:
- Это не Kanban product: README описывает dashboard как обзор agents, convoys, hooks, queues, issues, escalations.
- Нет явного built-in editor, hunk-level review или task attachment workflow как продуктовой возможности.
- Бюджеты не равны Paperclip budgets: у Gastown есть cost tiers/logging/guards, но я не нашёл hard monthly caps с automatic pause/cancel.
Публичная оценка:
- `Task dependencies` - `✅ Beads DAG waves`
- `Kanban board` - `❌ Dashboard, not Kanban`
- `Per-task code review` - `⚠️ Merge queue, no diff UI`
- `Budget controls` - `⚠️ Cost tiers + digest, no hard caps`
### Paperclip
Сильные факты:
- README: Node.js server + React UI that orchestrates a team of AI agents. Works with OpenClaw, Claude Code, Codex, Cursor, Bash, HTTP.
- README прямо заявляет org charts, budgets, governance, goal alignment, agent coordination.
- README under the hood: issues have company/project/goal/parent links, atomic checkout, execution locks, blocker dependencies, comments, documents, attachments, work products.
- Budget section: scoped budget policies with warning thresholds and hard stops, overspend pauses agents and cancels queued work automatically.
- `ui/src/components/KanbanBoard.tsx` содержит `backlog`, `todo`, `in_progress`, `in_review`, `blocked`, `done`, `cancelled` и drag-and-drop через `@dnd-kit`.
- `packages/shared/src/validators/work-product.ts` содержит work products: `preview_url`, `runtime_service`, `pull_request`, `branch`, `commit`, `artifact`, `document`; statuses include `ready_for_review`, `approved`, `changes_requested`, `merged`.
- Architecture docs: built-in stable adapters listed as Claude Local, Codex Local, Process, HTTP; adapter overview also lists OpenCode, Cursor, OpenClaw Gateway, Pi, Hermes and experimental Gemini.
- Execution policy docs: runtime enforces comment-required, review and approval stages; decisions are audited; reviewers/approvers can be agents or users.
- Workspace/runtime docs: services and jobs are manually controlled from UI, execution workspaces isolate checkout/branch/runtime state, and services are not auto-started by issue execution.
- Issue API docs: @mentions in comments trigger heartbeats and issue-thread interactions can request confirmations, ask questions, or suggest tasks through UI cards.
Ограничения:
- README явно говорит: Paperclip is not a code review tool. It orchestrates work, not pull requests.
- Нет hunk-level accept/reject UI и нет built-in code editor уровня нашего workbench.
- Это broader company/org control plane, а не coding cockpit.
- Work products are real in code, but public roadmap still has "Artifacts & Work Products" as future work, so public `Per-task code review` should stay `⚠️`, not `✅`.
Публичная оценка:
- `Kanban board` - `✅ 7 columns, drag-and-drop`
- `Per-task code review` - `⚠️ PR/work products, no inline diff`
- `Hunk-level review` - `❌ Bring your own review`
- `Budget controls` - `✅ Per-agent budgets + hard stops`
## Маркетинговая позиция
Честный framing для нас:
- Против Gastown продаём не "мы мощнее как orchestration OS", а **coding-team workbench**: review, logs, editor, live processes, attachments, operator UX.
- Против Paperclip продаём не "у нас больше governance", а **agentic IDE / coding cockpit**: hunk-level review, task-scoped developer logs, built-in editor, live process controls.
- `Multi-agent backend` у нас больше не надо показывать как "In development": публично корректнее писать `Claude, Codex + OpenCode teammates`.
## Второй глубокий проход
Что поменял после повторной проверки:
- Убрал Gemini из нашей публичной строки. Теперь у нас: `Claude, Codex + OpenCode teammates`.
- Paperclip `Agent-to-agent messaging` точнее как `Comments + @mentions`, а не как полноценный mailbox. @mentions будят агентов, но это не прямой peer mailbox как у нас или Gastown.
- Paperclip `Execution log viewer` усилил до `Run transcripts + ledger`: в UI есть `RunTranscriptView` и `IssueRunLedger`, а architecture docs описывают capture stdout, cost/session state и run records.
- Paperclip `Live processes` уточнил до `Manual services + previews`: runtime services/jobs есть в UI, но docs прямо говорят, что issue execution не стартует и не стопает их автоматически.
- Gastown `Session analysis` уточнил до `Session recall, feed, OTEL`: это сильная ops/observability модель, но не наш task-scoped analysis cockpit.
- Gastown `Budget controls` уточнил до `Cost tiers + digest, no hard caps`: есть model tiering, `gt costs record`, daily digest bead, но не нашёл Paperclip-style hard budget pause.
Вывод не поменялся: публично выгоднее и честнее продавать наш **coding workbench** - hunk review, task logs, editor, live processes, attachments, team UI. Не надо притворяться, что мы глубже Gastown как orchestration OS или глубже Paperclip как governance/budget company control plane.
## Третий глубокий проход по тексту таблицы
Что уточнил после проверки README/docs/source:
- Paperclip `Cross-team communication` заменил на `Company-scoped org work`: README говорит про multi-company isolation и org charts, но не про свободную коммуникацию между независимыми компаниями.
- Paperclip `Agent-to-agent messaging` заменил на `Comments + @mentions`: docs называют comments primary channel, а @mentions будят агента через heartbeat. Это не peer mailbox.
- Gastown `Linked tasks` заменил на `Beads deps + convoys`: source сильнее, чем просто "convoys", потому что stage/launch строит DAG по dependency edges.
- Gastown `Session analysis` заменил на `Session recall, feed, OTEL`: публично понятнее, чем термин `Seance`, но всё ещё отражает session discovery, TUI feed и OpenTelemetry.
- Gastown `Full autonomy` заменил на `Mayor, convoys, recovery`: не спорим с их глубокой autonomy моделью, но не перегружаем публичную таблицу внутренними health терминами.
- Paperclip `Zero setup` заменил на `npx + embedded Postgres`: quickstart реально идёт через `npx paperclipai onboard --yes`, и docs говорят, что embedded PostgreSQL создаётся автоматически.
- Paperclip `Price` уточнил до `OSS, self-hosted + infra`: честнее, потому что Paperclip account не нужен, но self-hosting и agent/provider runtime costs всё равно остаются.
## Четвёртый глубокий проход: Claude Code CLI и Cursor
Что поменял после проверки официальных docs Cursor и Claude Code:
- Claude Code CLI нельзя честно держать как слабый baseline: официальные `agent teams` уже дают direct teammate messaging, shared task list, mailbox, task dependencies, team review scenarios и hooks for quality gates. Поэтому я поднял Claude CLI в строках `Agent-to-agent messaging`, `Linked tasks`, `Full autonomy`, `Task dependencies`, `Review workflow`.
- Но Claude Code agent teams всё ещё experimental, disabled by default, требуют `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS`, работают в CLI/terminal display modes и имеют known limitations. Поэтому наш selling point против Claude CLI - не "у них нет teams", а **у них нет полноценного product UI/cockpit**: kanban, task attachments, hunk review UI, task logs, live process section, cross-team UI, cost/context dashboard.
- Claude Code `Multi-agent backend` теперь точнее как `Claude-only experimental teams`, а не просто `Claude-only teams`.
- Claude Code `Git worktree isolation` точнее как `Manual worktrees`: official workflows рекомендуют git worktree для параллельных sessions, но это не автоматическая продуктовая isolation как у нас/Gastown/Paperclip.
- Cursor `Full autonomy` точнее как `Background agents, not teams`: background agents сильные, но это не team backend with shared task list/mailbox.
- Cursor `Execution log viewer` и `Live processes` понижены до `⚠️`: есть agent chat/terminal/background terminals, но нет task-scoped execution timeline и process URL section как у нас.
- Cursor `Flexible autonomy` понижен до `⚠️`: official background-agent security docs прямо говорят, что background agents auto-run terminal commands, в отличие от foreground approvals.
- Cursor `Git worktree isolation` понижен до `⚠️ Background branches/VMs`: docs говорят про isolated remote machines and separate branches, не про встроенный local worktree strategy.
- Cursor `Price` заменил с `$0-$200/mo` на `Free + paid usage`, потому что pricing/usage docs теперь акцентируют included API usage, usage dashboard, Bugbot pricing и background-agent spend limits.
Итоговая позиция после четвёртого прохода:
- Против Claude Code CLI мы не врём, что у него нет multi-agent primitives. У него они есть, но experimental and CLI-first. Мы продаём **операторский UI и review/workbench layer**.
- Против Cursor мы не врём, что у него нет background agents или review. У него есть сильный IDE/PR story. Мы продаём **team/task orchestration, task-scoped logs, cross-agent workflow and review cockpit**.
## Scores
| Критерий | Agent Teams | Gastown | Paperclip |
|---|---:|---:|---:|
| Coding cockpit | **9.2** | 5.6 | 6.8 |
| Orchestration depth | 7.6 | **9.2** | 8.8 |
| Governance / budget control | 6.7 | 6.0 | **9.4** |
| Review UX | **9.3** | 5.8 | 6.2 |
| Setup simplicity | **8.4** | 4.7 | 7.1 |
Вывод по 10-балльной уверенности:
- Agent Teams против Gastown: 🎯 9 🛡️ 8 🧠 5 - сравнение честное, потому что мы отдаём Gastown orchestration depth, но забираем workbench.
- Agent Teams против Paperclip: 🎯 9 🛡️ 8 🧠 6 - сравнение честное, потому что Paperclip сильнее в budgets/governance, но слабее как code-review/editor cockpit.
- Agent Teams против Cursor: 🎯 9 🛡️ 9 🧠 5 - сравнение честное, потому что Cursor сильнее как IDE, но не как multi-agent team/task OS.
- Agent Teams против Claude Code CLI: 🎯 9 🛡️ 9 🧠 6 - сравнение честное только после повышения Claude CLI по official agent teams primitives; наш выигрыш теперь UI/workbench, не наличие базовой team механики.
- Публичная таблица после Claude/Cursor прохода: 🎯 9 🛡️ 9 🧠 5 - около 55 строк изменения в README/landing плюс локали.
## Источники
- Gastown repo: <https://github.com/gastownhall/gastown>
- Gastown v1.0.1: <https://github.com/gastownhall/gastown/releases/tag/v1.0.1>
- Gastown convoy implementation: <https://github.com/gastownhall/gastown/blob/main/internal/cmd/convoy_launch.go>
- Gastown convoy skill docs: <https://github.com/gastownhall/gastown/tree/main/docs/skills/convoy>
- Gastown provider guide: <https://github.com/gastownhall/gastown/blob/main/docs/agent-provider-integration.md>
- Gastown scheduler docs: <https://github.com/gastownhall/gastown/blob/main/docs/design/scheduler.md>
- Paperclip repo: <https://github.com/paperclipai/paperclip>
- Paperclip v2026.428.0: <https://github.com/paperclipai/paperclip/releases/tag/v2026.428.0>
- Paperclip Kanban source: <https://github.com/paperclipai/paperclip/blob/master/ui/src/components/KanbanBoard.tsx>
- Paperclip work products source: <https://github.com/paperclipai/paperclip/blob/master/packages/shared/src/validators/work-product.ts>
- Paperclip architecture docs: <https://github.com/paperclipai/paperclip/blob/master/docs/start/architecture.md>
- Paperclip execution policy docs: <https://github.com/paperclipai/paperclip/blob/master/docs/guides/execution-policy.md>
- Paperclip costs and budgets docs: <https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/costs-and-budgets.md>
- Paperclip runtime services docs: <https://github.com/paperclipai/paperclip/blob/master/docs/guides/board-operator/execution-workspaces-and-runtime-services.md>
- Cursor Background Agents: <https://docs.cursor.com/en/background-agents>
- Cursor Diffs & Review: <https://docs.cursor.com/en/agent/review>
- Cursor Bugbot: <https://docs.cursor.com/en/bugbot>
- Cursor usage/pricing: <https://docs.cursor.com/en/account/usage>
- Claude Code agent teams: <https://code.claude.com/docs/en/agent-teams>
- Claude Code subagents: <https://code.claude.com/docs/en/sub-agents>
- Claude Code common workflows: <https://code.claude.com/docs/en/common-workflows>
- Claude Code costs: <https://code.claude.com/docs/en/costs>

View file

@ -1,5 +1,7 @@
# Реальные конкуренты для Comparison в README # Реальные конкуренты для Comparison в README
> ⚠️ Update 2026-05-05: публичная таблица README/landing теперь сравнивает нас с `Gastown` и `Paperclip`, а не с `Claude Code Agent Teams` и `GoClaw`. Актуальная research-опора: [gastown-paperclip-comparison-2026-05-05.md](gastown-paperclip-comparison-2026-05-05.md). Ниже оставлен старый broader draft как исторический контекст.
> Дата проверки: 2026-04-13 > Дата проверки: 2026-04-13
> Статус: внутренний comparison draft > Статус: внутренний comparison draft
> Цель: заменить в нашем внутреннем thinking `Vibe Kanban` и `Aperant` на реальные ориентиры - `Gastown`, `Claude Code Agent Teams`, `GoClaw` > Цель: заменить в нашем внутреннем thinking `Vibe Kanban` и `Aperant` на реальные ориентиры - `Gastown`, `Claude Code Agent Teams`, `GoClaw`
@ -55,7 +57,7 @@
| **Per-task code review** | ✅ Per-task diff review with accept / reject / comment flow | ❌ | ❌ | ⚠️ Task approval exists, but not inline code diff review | | **Per-task code review** | ✅ Per-task diff review with accept / reject / comment flow | ❌ | ❌ | ⚠️ Task approval exists, but not inline code diff review |
| **Flexible autonomy** | ✅ Granular approvals, notifications, autonomy controls | ✅ Strong human gates, escalation and intervention, mostly via CLI/TUI | ⚠️ Plan approval, hooks and permissions exist, but control plane is thin | ✅ Team settings, approval workflows, exec approval, task approval | | **Flexible autonomy** | ✅ Granular approvals, notifications, autonomy controls | ✅ Strong human gates, escalation and intervention, mostly via CLI/TUI | ⚠️ Plan approval, hooks and permissions exist, but control plane is thin | ✅ Team settings, approval workflows, exec approval, task approval |
| **Git worktree isolation** | ✅ Optional per-agent worktree strategy | ✅ Core architectural primitive | ⚠️ Manual worktrees exist in Claude Code, but not as the native team model | ❌ Not a core team isolation model | | **Git worktree isolation** | ✅ Optional per-agent worktree strategy | ✅ Core architectural primitive | ⚠️ Manual worktrees exist in Claude Code, but not as the native team model | ❌ Not a core team isolation model |
| **Multi-agent backend** | ⚠️ Claude is mature; Codex/Gemini plumbing exists in code but is still emerging as product surface | ✅ Claude Code, Codex, Gemini, Copilot and other runtimes | ❌ Claude-first only, models per teammate but no real multi-provider backend | ✅ 20+ providers including Claude CLI and ChatGPT OAuth | | **Multi-agent backend** | ⚠️ Claude is mature; Codex/OpenCode plumbing exists in code but is still emerging as product surface | ✅ Claude Code, Codex, Gemini, Copilot and other runtimes | ❌ Claude-first only, models per teammate but no real multi-provider backend | ✅ 20+ providers including Claude CLI and ChatGPT OAuth |
| **Price** | Free OSS UI, but a Claude Code plan is still needed today | Free OSS, but you still pay for the underlying runtime plans/seats you use | Claude subscription | Free self-hosted OSS, but infra + provider/API/subscription costs remain | | **Price** | Free OSS UI, but a Claude Code plan is still needed today | Free OSS, but you still pay for the underlying runtime plans/seats you use | Claude subscription | Free self-hosted OSS, but infra + provider/API/subscription costs remain |
## Самые важные выводы по matrix ## Самые важные выводы по matrix
@ -435,7 +437,7 @@ GoClaw выигрывает у нас по:
## Места, где надо быть особенно честными про нас ## Места, где надо быть особенно честными про нас
- `Multi-agent backend` у нас пока не так зрел, как это можно прочитать из одной строки README. В коде есть мосты и статусы для `Anthropic`, `Codex`, `Gemini`, но продуктово основной путь всё ещё Claude-first. - `Multi-agent backend` у нас пока не так зрел, как это можно прочитать из одной строки README. В коде есть мосты и статусы для `Anthropic`, `Codex`, `OpenCode`, но продуктово основной путь всё ещё Claude-first.
- `Zero setup` у нас честно сильный именно для Claude Code path. - `Zero setup` у нас честно сильный именно для Claude Code path.
- `Cross-team communication` у нас сильнее, чем у этих конкурентов, но cross-team attachments не выглядят как полностью общий happy path. - `Cross-team communication` у нас сильнее, чем у этих конкурентов, но cross-team attachments не выглядят как полностью общий happy path.

View file

@ -1,6 +1,6 @@
# Member Work Sync Control Plane Plan # Member Work Sync Control Plane Plan
**Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and opt-in Phase 2 nudge outbox/dispatcher/scheduler implemented **Status:** Phase 1, Phase 1.5 observability, minimal read-only member details surface, and active-by-default Phase 2 nudge outbox/dispatcher/scheduler implemented
**Scope:** Team management, task work synchronization, agent work coordination **Scope:** Team management, task work synchronization, agent work coordination
**Primary repo:** `claude_team` **Primary repo:** `claude_team`
**Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller` **Secondary write-boundary repo:** `agent_teams_orchestrator` / `agent-teams-controller`
@ -35,11 +35,11 @@ Current implementation note:
- Phase 1 does not insert inbox messages, send nudges, mark tasks/messages read, or change `TeamTaskStallMonitor` semantics. - 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 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. - 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. - 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 guard 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. - Phase 2 nudge delivery is active by default in production composition. Safety is provided by internal guards: `shadow_ready`, current fingerprint, active team, busy signal, watchdog cooldown, rate limit, and idempotent outbox.
- 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. - Dispatcher use case runs after queued reconcile and is also exposed through the facade. 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. - 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. - A feature-owned dispatch scheduler wakes due retryable outbox items for lifecycle-active teams when a lifecycle-active team source is available. 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. - 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. - 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. - Phase 2 dispatch stays blocked until real shadow metrics confirm that `needs_sync` churn and false positives are acceptably low.
@ -378,7 +378,7 @@ Pre-coding hardening checklist:
- Add identity tests before report persistence. `from` is not authority unless runtime context or report token proves it. - Add identity tests before report persistence. `from` is not authority unless runtime context or report token proves it.
- Treat every app restart as a replay scenario. Pending intents, queued reconciles, and stale reports must be safe to process again. - Treat every app restart as a replay scenario. Pending intents, queued reconciles, and stale reports must be safe to process again.
- Make every Phase 2 side effect idempotent before adding the dispatcher. - Make every Phase 2 side effect idempotent before adding the dispatcher.
- Add one explicit kill switch per side-effect class: reconcile/status, report acceptance, and nudges. - Keep side effects bounded by internal guards and narrow ports. Do not add permanent product switches for reconcile/status, report acceptance, or nudges.
- Do not merge watchdog and work-sync concepts. Work-sync is agenda observation; watchdog is semantic progress. - Do not merge watchdog and work-sync concepts. Work-sync is agenda observation; watchdog is semantic progress.
Failure-mode matrix: Failure-mode matrix:
@ -801,7 +801,7 @@ It should not mean:
- a delivery retry marker was appended; - a delivery retry marker was appended;
- a status condition timestamp changed. - a status condition timestamp changed.
Phase 1 must track `fingerprintChangeCount` and store the last few fingerprint transition reasons. If this count rises without visible agenda changes, do not enable Phase 2 nudges. Phase 1 must track `fingerprintChangeCount` and store the last few fingerprint transition reasons. If this count rises without visible agenda changes, keep `phase2Readiness` blocked until the source of churn is fixed.
Recommended transition diagnostic: Recommended transition diagnostic:
@ -1364,7 +1364,7 @@ Expired leases are ignored by `SyncDecisionPolicy`.
### 10.4 Shadow Would-Nudge Semantics ### 10.4 Shadow Would-Nudge Semantics
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`. Read-only status and diagnostics may compute `wouldNudgeCount`, but must not enqueue or send. Production composition always wires the outbox and inbox nudge sink; side effects stay limited to queued reconcile planning and dispatcher delivery guards.
`wouldNudge` is true only when all are true: `wouldNudge` is true only when all are true:
@ -1911,7 +1911,7 @@ Preferred Phase 1 read surface:
- extend `task_briefing` with a compact `workSync` block; - extend `task_briefing` with a compact `workSync` block;
- include current `agendaFingerprint`; - include current `agendaFingerprint`;
- include a short actionable agenda preview; - include a short actionable agenda preview;
- include report instructions only when the feature is enabled. - include report instructions only when the report tool is available.
Example `task_briefing` addition: Example `task_briefing` addition:
@ -2235,7 +2235,7 @@ Dispatcher revalidation:
Before inserting an inbox nudge, dispatcher must re-read: Before inserting an inbox nudge, dispatcher must re-read:
- feature gate state; - current `phase2Readiness`;
- current roster membership; - current roster membership;
- current agenda fingerprint; - current agenda fingerprint;
- latest accepted report; - latest accepted report;
@ -2729,9 +2729,9 @@ busy suppressions: 8
### 18.1 Phase 2 Entry Thresholds ### 18.1 Phase 2 Entry Thresholds
Do not enable nudges until shadow metrics are stable. Do not let nudges deliver until shadow metrics are stable and `phase2Readiness=shadow_ready`.
Recommended gates: Recommended guardrails:
| Metric | Target before Phase 2 | | Metric | Target before Phase 2 |
|---|---:| |---|---:|
@ -2742,7 +2742,7 @@ Recommended gates:
| busy suppression correctness | no known prompt during active tool/runtime turn | | busy suppression correctness | no known prompt during active tool/runtime turn |
| report intent replay errors | 0 lost accepted reports | | report intent replay errors | 0 lost accepted reports |
If a metric misses the target, keep Phase 2 disabled and fix the specific source of noise. Do not compensate with a shorter lease or more nudges. If a metric misses the target, keep `phase2Readiness` blocked/collecting and fix the specific source of noise. Do not compensate with a shorter lease or more nudges.
--- ---
@ -3039,7 +3039,8 @@ No accelerator is proof.
Current implementation: Current implementation:
- tool-finish enqueue and tool-activity busy suppression are implemented through `TeamChangeEvent` and the feature-owned busy signal; - 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; - Claude Stop hook settings are wired through the `member-work-sync` feature facade into `TeamProvisioningService`, then merged into Anthropic launch settings;
- Codex native and OpenCode turn-settled signals are wired through provider-specific runtime env/spool emitters, shared payload normalization, `RuntimeTurnSettledIngestor`, and the feature-owned drain scheduler. OpenCode bridge env receives the spool root before `OpenCodeBridgeCommandClient` construction;
- 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. - 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.
--- ---
@ -3070,7 +3071,7 @@ Do not add:
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED` - `CLAUDE_TEAM_MEMBER_WORK_SYNC_ENABLED`
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY` - `CLAUDE_TEAM_MEMBER_WORK_SYNC_SHADOW_ONLY`
- `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED` - any new member-work-sync nudge env or feature flag
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. 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.
@ -3088,7 +3089,7 @@ const MEMBER_WORK_SYNC_STILL_WORKING_LEASE_MS = 10 * 60_000;
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2; const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
``` ```
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. No config/env kill switch is part of the design. Production nudge safety must come from dispatcher guards and typed runtime defaults, without disabling agenda/status/report validation.
--- ---
@ -3238,7 +3239,7 @@ Phase 2 must not start if any of these are true:
- report validation can accept leases with claimed `from` only and no trusted identity/report token; - report validation can accept leases with claimed `from` only and no trusted identity/report token;
- queue can run more than one reconcile for the same member concurrently; - queue can run more than one reconcile for the same member concurrently;
- watchdog cooldown integration is untested; - watchdog cooldown integration is untested;
- outbox dispatcher can send while feature is disabled; - outbox dispatcher can send without `shadow_ready`;
- outbox dispatcher can send for a stopped/cancelled team; - outbox dispatcher can send for a stopped/cancelled team;
- pending intent replay can turn stale reports into accepted leases; - pending intent replay can turn stale reports into accepted leases;
- fingerprint transition diagnostics are missing; - fingerprint transition diagnostics are missing;
@ -3379,8 +3380,8 @@ Step order:
- Writes status only through `MemberWorkSyncStatusStorePort`. - Writes status only through `MemberWorkSyncStatusStorePort`.
- Does not send any message or call runtime delivery. - Does not send any message or call runtime delivery.
10. Add shadow trigger wiring behind feature gate. 10. Add shadow trigger wiring without an env gate.
- Default shadow status can be on, but all side effects are status-only. - Default shadow status is on, while read-only status/diagnostics stay passive.
- Use quiet-window queue and bounded concurrency. - Use quiet-window queue and bounded concurrency.
- Wire broad team/task change events only after domain tests are green. - Wire broad team/task change events only after domain tests are green.
- Drop queued entries when team/member is removed or stopped. - Drop queued entries when team/member is removed or stopped.
@ -3536,12 +3537,12 @@ Cut 3 stop criteria:
### 27.4 Phase 2: Nudges Later, Separate Work ### 27.4 Phase 2: Nudges Later, Separate Work
Do not start Phase 2 until shadow metrics prove low noise. Do not rely on Phase 2 delivery until shadow metrics prove low noise.
Phase 2 sequence: Phase 2 sequence:
1. Add outbox schema and idempotency key. 1. Add outbox schema and idempotency key.
2. Add dispatcher with feature gate default off. 2. Add dispatcher active by default behind internal guards.
3. Add stale revalidation before dispatch. 3. Add stale revalidation before dispatch.
4. Add watchdog cooldown integration. 4. Add watchdog cooldown integration.
5. Add one-in-flight per `(teamName, memberName, fingerprint)`. 5. Add one-in-flight per `(teamName, memberName, fingerprint)`.

View file

@ -248,7 +248,7 @@ Rules:
- turn-settled does not directly nudge; - turn-settled does not directly nudge;
- turn-settled does not count as meaningful task progress; - turn-settled does not count as meaningful task progress;
- watchdog cooldowns still prevent duplicate nudges; - watchdog cooldowns still prevent duplicate nudges;
- existing `member-work-sync` nudge side-effects gate remains the only way to deliver sync nudges. - `member-work-sync` dispatcher remains the only path that can deliver sync nudges, and it must pass its internal guards first.
--- ---
@ -2039,7 +2039,7 @@ More reconcile triggers could expose existing Phase 2 nudges.
Mitigation: Mitigation:
- current nudge side effects remain gated by `CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED`; - nudges are active by default, but delivery remains bounded by dispatcher guards;
- queue quiet window debounces events; - queue quiet window debounces events;
- outbox has one item per fingerprint; - outbox has one item per fingerprint;
- dispatcher revalidates busy/watchdog cooldown before delivery. - dispatcher revalidates busy/watchdog cooldown before delivery.
@ -2472,7 +2472,7 @@ and a valid OpenCode turn-settled event file
when drainRuntimeTurnSettledEvents runs when drainRuntimeTurnSettledEvents runs
then queue receives member-turn-settled then queue receives member-turn-settled
and member-work-sync status is recomputed and member-work-sync status is recomputed
and no direct nudge is sent unless existing nudge side-effects are enabled and no direct nudge is sent outside the existing outbox/dispatcher path
``` ```
### 13.4 Live E2E Prototype Test ### 13.4 Live E2E Prototype Test

View file

@ -1,7 +1,7 @@
# Member Work Sync Runtime Stop Hook Plan # Member Work Sync Runtime Stop Hook Plan
**Status:** design ready, not implemented **Status:** implemented for provider-neutral spool/drain, Claude Stop hook settings, and Codex/OpenCode turn-settled adapters
**Scope:** `member-work-sync`, Claude runtime hook integration, future Codex hook adapter **Scope:** `member-work-sync`, Claude runtime hook integration, Codex/OpenCode runtime turn-settled adapters
**Primary repo:** `claude_team` **Primary repo:** `claude_team`
**Secondary dependency:** `agent_teams_orchestrator` runtime hook payload contract **Secondary dependency:** `agent_teams_orchestrator` runtime hook payload contract
**Feature name:** `member-work-sync` **Feature name:** `member-work-sync`
@ -33,15 +33,15 @@ Recommended approach:
Why this is the safest direction: Why this is the safest direction:
- `Stop` is a useful "turn settled" signal, but not proof that work is complete. - `Stop` is a useful "turn settled" signal, but not proof that work is complete.
- `TeammateIdle` is not used as the base because it is Claude/team-specific and does not generalize to future Codex runtime hooks. - `TeammateIdle` is not used as the base because it is Claude/team-specific and does not generalize to Codex/OpenCode turn-settled adapters.
- Hook execution must be fast, non-blocking, and fail-open. - Hook execution must be fast, non-blocking, and fail-open.
- The existing `member-work-sync` agenda fingerprint, lease, cooldown, busy signal, and watchdog separation remain authoritative. - The existing `member-work-sync` agenda fingerprint, lease, cooldown, busy signal, and watchdog separation remain authoritative.
- Codex can be added later by implementing a second provider adapter that emits the same normalized event contract. - Codex/OpenCode use provider adapters that emit the same normalized event contract.
Architecture checkpoint: Architecture checkpoint:
- No blocker question is required before implementation. The safest defaults are clear. - No blocker question is required before implementation. The safest defaults are clear.
- The only intentionally deferred decision is production Codex installation. Codex receives a tested adapter seam, but no production launch behavior until its hook payload/config contract is verified. - Codex/OpenCode production wiring is fail-open: missing runtime turn-settled env disables telemetry for that provider process, but does not fail team launch or prompt delivery.
- The implementation should be done as an extension of `member-work-sync`, not as ad hoc logic inside `TeamProvisioningService`. - The implementation should be done as an extension of `member-work-sync`, not as ad hoc logic inside `TeamProvisioningService`.
- The hook pipeline is an input signal. It must not become a second watchdog, a second delivery ledger, or a runtime liveness detector. - The hook pipeline is an input signal. It must not become a second watchdog, a second delivery ledger, or a runtime liveness detector.
@ -174,7 +174,7 @@ Reason:
- Existing leases, fingerprints, cooldowns, and watchdog separation still decide behavior. - Existing leases, fingerprints, cooldowns, and watchdog separation still decide behavior.
- A flag would add another state combination without reducing the main risk, which is attribution correctness. - A flag would add another state combination without reducing the main risk, which is attribution correctness.
If an emergency kill switch is needed later, prefer an internal config/env-only disable around hook settings generation, not branching inside the core policy. If emergency disable behavior is needed later, patch the narrow hook composition wiring directly rather than adding a permanent config/env flag or branching inside the core policy.
--- ---

View file

@ -10,8 +10,8 @@ interface CellValue {
interface ComparisonRow { interface ComparisonRow {
feature: string feature: string
us: CellValue us: CellValue
vibeKanban: CellValue gastown: CellValue
aperant: CellValue paperclip: CellValue
cursor: CellValue cursor: CellValue
claudeCli: CellValue claudeCli: CellValue
} }
@ -20,161 +20,177 @@ const rows = computed<ComparisonRow[]>(() => [
{ {
feature: t('comparison.features.crossTeam'), feature: t('comparison.features.crossTeam'),
us: { status: 'yes' }, us: { status: 'yes' },
vibeKanban: { status: 'no' }, gastown: { status: 'partial', note: 'Cross-rig coordination' },
aperant: { status: 'no' }, paperclip: { status: 'partial', note: 'Company-scoped org work' },
cursor: { status: 'na' }, cursor: { status: 'na' },
claudeCli: { status: 'no' }, claudeCli: { status: 'no' },
}, },
{ {
feature: t('comparison.features.agentMessaging'), feature: t('comparison.features.agentMessaging'),
us: { status: 'yes', note: 'Native real-time mailbox' }, us: { status: 'yes', note: 'Native real-time mailbox' },
vibeKanban: { status: 'no', note: 'Agents are independent' }, gastown: { status: 'yes', note: 'Mailboxes + handoffs' },
aperant: { status: 'no', note: 'Fixed pipeline' }, paperclip: { status: 'partial', note: 'Comments + @mentions' },
cursor: { status: 'no' }, cursor: { status: 'no' },
claudeCli: { status: 'partial', note: 'Built-in (no UI)' }, claudeCli: { status: 'yes', note: 'Team mailbox, no UI' },
}, },
{ {
feature: t('comparison.features.linkedTasks'), feature: t('comparison.features.linkedTasks'),
us: { status: 'yes', note: 'Cross-references in messages' }, us: { status: 'yes', note: 'Cross-refs + dependencies' },
vibeKanban: { status: 'partial', note: 'Subtasks only' }, gastown: { status: 'partial', note: 'Beads deps + convoys' },
aperant: { status: 'no' }, paperclip: { status: 'yes', note: 'Goals, parents, blockers' },
cursor: { status: 'no' }, cursor: { status: 'no' },
claudeCli: { status: 'no' }, claudeCli: { status: 'yes', note: 'Shared task list' },
}, },
{ {
feature: t('comparison.features.sessionAnalysis'), feature: t('comparison.features.sessionAnalysis'),
us: { status: 'yes', note: '6-category token tracking' }, us: { status: 'yes', note: 'Task logs + token tracking' },
vibeKanban: { status: 'no' }, gastown: { status: 'partial', note: 'Session recall, feed, OTEL' },
aperant: { status: 'partial', note: 'Execution logs' }, paperclip: { status: 'partial', note: 'Run transcripts + cost audit' },
cursor: { status: 'no' }, cursor: { status: 'no' },
claudeCli: { status: 'no' }, claudeCli: { status: 'partial', note: 'Usage command, no UI' },
}, },
{ {
feature: t('comparison.features.taskAttachments'), feature: t('comparison.features.taskAttachments'),
us: { status: 'yes', note: 'Auto-attach, agents read & attach' }, us: { status: 'yes', note: 'Auto-attach, agents read & attach' },
vibeKanban: { status: 'no' }, gastown: { status: 'no', note: 'Not task-level' },
aperant: { status: 'yes', note: 'Images + files' }, paperclip: { status: 'yes', note: 'Docs, attachments, work products' },
cursor: { status: 'partial', note: 'Chat session only' }, cursor: { status: 'partial', note: 'Chat session only' },
claudeCli: { status: 'no' }, claudeCli: { status: 'partial', note: 'Chat images only' },
}, },
{ {
feature: t('comparison.features.hunkReview'), feature: t('comparison.features.hunkReview'),
us: { status: 'yes', note: 'Accept / reject individual hunks' }, us: { status: 'yes', note: 'Accept / reject individual hunks' },
vibeKanban: { status: 'no' }, gastown: { status: 'no' },
aperant: { status: 'no' }, paperclip: { status: 'no', note: 'Bring your own review' },
cursor: { status: 'yes' }, cursor: { status: 'yes' },
claudeCli: { status: 'no' }, claudeCli: { status: 'no' },
}, },
{ {
feature: t('comparison.features.codeEditor'), feature: t('comparison.features.codeEditor'),
us: { status: 'yes', note: 'With Git support' }, us: { status: 'yes', note: 'With Git support' },
vibeKanban: { status: 'no' }, gastown: { status: 'no' },
aperant: { status: 'no' }, paperclip: { status: 'no', note: 'Control plane, not editor' },
cursor: { status: 'yes', note: 'Full IDE' }, cursor: { status: 'yes', note: 'Full IDE' },
claudeCli: { status: 'no' }, claudeCli: { status: 'no' },
}, },
{ {
feature: t('comparison.features.fullAutonomy'), feature: t('comparison.features.fullAutonomy'),
us: { status: 'yes', note: 'Create, assign, review end-to-end' }, us: { status: 'yes', note: 'Create, assign, review end-to-end' },
vibeKanban: { status: 'no', note: 'Human manages tasks' }, gastown: { status: 'yes', note: 'Mayor, convoys, recovery' },
aperant: { status: 'no', note: 'Fixed pipeline' }, paperclip: { status: 'yes', note: 'Heartbeats + governance' },
cursor: { status: 'partial', note: 'Isolated tasks only' }, cursor: { status: 'partial', note: 'Background agents, not teams' },
claudeCli: { status: 'partial', note: 'No UI' }, claudeCli: { status: 'yes', note: 'Experimental CLI teams' },
}, },
{ {
feature: t('comparison.features.taskDeps'), feature: t('comparison.features.taskDeps'),
us: { status: 'yes', note: 'Guaranteed ordering' }, us: { status: 'yes', note: 'Guaranteed ordering' },
vibeKanban: { status: 'no' }, gastown: { status: 'yes', note: 'DAG waves via Beads' },
aperant: { status: 'partial', note: 'Within plan only' }, paperclip: { status: 'yes', note: 'Blockers + execution locks' },
cursor: { status: 'no' }, cursor: { status: 'no' },
claudeCli: { status: 'partial', note: 'No UI, no notifications' }, claudeCli: { status: 'yes', note: 'Team task deps, no UI' },
}, },
{ {
feature: t('comparison.features.reviewWorkflow'), feature: t('comparison.features.reviewWorkflow'),
us: { status: 'yes', note: 'Agents review each other' }, us: { status: 'yes', note: 'Agents review each other' },
vibeKanban: { status: 'no' }, gastown: { status: 'partial', note: 'Refinery merge queue' },
aperant: { status: 'partial', note: 'Auto QA pipeline' }, paperclip: { status: 'yes', note: 'Approvals + governance' },
cursor: { status: 'no' }, cursor: { status: 'partial', note: 'PR/BugBot only' },
claudeCli: { status: 'partial', note: 'No UI' }, claudeCli: { status: 'yes', note: 'Team review, no UI' },
}, },
{ {
feature: t('comparison.features.zeroSetup'), feature: t('comparison.features.zeroSetup'),
us: { status: 'yes' }, us: { status: 'yes', note: 'Guided runtime setup' },
vibeKanban: { status: 'no', note: 'Config required' }, gastown: { status: 'no', note: 'Go/Git/Dolt/Beads/tmux' },
aperant: { status: 'no', note: 'Config required' }, paperclip: { status: 'partial', note: 'npx + embedded Postgres' },
cursor: { status: 'yes' }, cursor: { status: 'yes' },
claudeCli: { status: 'partial', note: 'CLI install required' }, claudeCli: { status: 'partial', note: 'CLI + env flag' },
}, },
{ {
feature: t('comparison.features.kanban'), feature: t('comparison.features.kanban'),
us: { status: 'yes', note: '5 columns, real-time' }, us: { status: 'yes', note: '5 columns, real-time' },
vibeKanban: { status: 'yes' }, gastown: { status: 'no', note: 'Dashboard, not Kanban' },
aperant: { status: 'yes', note: '6 columns (pipeline)' }, paperclip: { status: 'yes', note: '7 columns, drag-and-drop' },
cursor: { status: 'no' }, cursor: { status: 'no' },
claudeCli: { status: 'no' }, claudeCli: { status: 'no' },
}, },
{ {
feature: t('comparison.features.execLog'), feature: t('comparison.features.execLog'),
us: { status: 'yes', note: 'Tool calls, reasoning, timeline' }, us: { status: 'yes', note: 'Tool calls, reasoning, timeline' },
vibeKanban: { status: 'no' }, gastown: { status: 'partial', note: 'Feed, OTEL, dashboard' },
aperant: { status: 'yes', note: 'Phase-based logs' }, paperclip: { status: 'yes', note: 'Run transcripts + ledger' },
cursor: { status: 'yes' }, cursor: { status: 'partial', note: 'Agent chat + terminal' },
claudeCli: { status: 'no' }, claudeCli: { status: 'no' },
}, },
{ {
feature: t('comparison.features.liveProcesses'), feature: t('comparison.features.liveProcesses'),
us: { status: 'yes', note: 'View, stop, open URLs' }, us: { status: 'yes', note: 'View, stop, open URLs' },
vibeKanban: { status: 'no' }, gastown: { status: 'partial', note: 'Agent health dashboard' },
aperant: { status: 'no' }, paperclip: { status: 'partial', note: 'Manual services + previews' },
cursor: { status: 'yes' }, cursor: { status: 'partial', note: 'Native terminal only' },
claudeCli: { status: 'no' }, claudeCli: { status: 'no' },
}, },
{ {
feature: t('comparison.features.perTaskReview'), feature: t('comparison.features.perTaskReview'),
us: { status: 'yes', note: 'Accept / reject / comment' }, us: { status: 'yes', note: 'Accept / reject / comment' },
vibeKanban: { status: 'partial', note: 'PR-level only' }, gastown: { status: 'partial', note: 'Merge queue, no diff UI' },
aperant: { status: 'partial', note: 'File-level only' }, paperclip: { status: 'partial', note: 'PR/work products, no diff UI' },
cursor: { status: 'yes', note: 'BugBot on PRs' }, cursor: { status: 'yes', note: 'BugBot on PRs' },
claudeCli: { status: 'no' }, claudeCli: { status: 'no' },
}, },
{ {
feature: t('comparison.features.flexAutonomy'), feature: t('comparison.features.flexAutonomy'),
us: { status: 'yes', note: 'Granular settings, notifications' }, us: { status: 'yes', note: 'Per-action approvals + notifications' },
vibeKanban: { status: 'no' }, gastown: { status: 'yes', note: 'Gates, escalation, recovery' },
aperant: { status: 'partial', note: 'Plan approval only' }, paperclip: { status: 'yes', note: 'Board approvals, pause, terminate' },
cursor: { status: 'yes' }, cursor: { status: 'partial', note: 'BG agents auto-run commands' },
claudeCli: { status: 'yes' }, claudeCli: { status: 'yes', note: 'Permissions + hooks' },
}, },
{ {
feature: t('comparison.features.worktree'), feature: t('comparison.features.worktree'),
us: { status: 'yes', note: 'Optional' }, us: { status: 'yes', note: 'Optional' },
vibeKanban: { status: 'partial', note: 'Mandatory' }, gastown: { status: 'yes', note: 'Core primitive' },
aperant: { status: 'partial', note: 'Mandatory' }, paperclip: { status: 'yes', note: 'Worktrees / branches' },
cursor: { status: 'yes' }, cursor: { status: 'partial', note: 'Background branches/VMs' },
claudeCli: { status: 'yes' }, claudeCli: { status: 'partial', note: 'Manual worktrees' },
}, },
{ {
feature: t('comparison.features.multiAgent'), feature: t('comparison.features.multiAgent'),
us: { status: 'soon', note: 'In development', noteLink: 'https://github.com/Alishahryar1/free-claude-code' }, us: { status: 'yes', note: 'Claude, Codex + OpenCode teammates' },
vibeKanban: { status: 'yes', note: '6+ agents' }, gastown: { status: 'yes', note: 'Claude, Codex, Gemini, Copilot + more' },
aperant: { status: 'yes', note: '11 providers' }, paperclip: { status: 'yes', note: 'BYO agents: Claude, Codex, Cursor/OpenCode, HTTP' },
cursor: { status: 'yes', note: 'Multi-model' }, cursor: { status: 'partial', note: 'Multi-model agents, no team backend' },
claudeCli: { status: 'na' }, claudeCli: { status: 'partial', note: 'Claude-only experimental teams' },
},
{
feature: t('comparison.features.orgGovernance'),
us: { status: 'partial', note: 'Roles + approvals, no org chart' },
gastown: { status: 'partial', note: 'Roles + escalation' },
paperclip: { status: 'yes', note: 'Org chart + board governance' },
cursor: { status: 'partial', note: 'Team admin only' },
claudeCli: { status: 'no' },
},
{
feature: t('comparison.features.budgetControls'),
us: { status: 'partial', note: 'Cost/token visibility, no hard caps' },
gastown: { status: 'partial', note: 'Cost tiers + digest, no hard caps' },
paperclip: { status: 'yes', note: 'Per-agent budgets + hard stops' },
cursor: { status: 'partial', note: 'Usage + BG spend limits' },
claudeCli: { status: 'partial', note: '/cost + workspace limits' },
}, },
{ {
feature: t('comparison.features.price'), feature: t('comparison.features.price'),
us: { status: 'free' }, us: { status: 'free', note: 'OSS, provider access needed' },
vibeKanban: { status: 'text', note: 'Free / $30/mo' }, gastown: { status: 'free', note: 'OSS, runtime plans needed' },
aperant: { status: 'free' }, paperclip: { status: 'free', note: 'OSS, self-hosted + infra' },
cursor: { status: 'text', note: '$0$200/mo' }, cursor: { status: 'text', note: 'Free + paid usage' },
claudeCli: { status: 'text', note: 'Claude subscription' }, claudeCli: { status: 'text', note: 'Claude plan or API usage' },
}, },
]) ])
const competitors = [ const competitors = [
{ key: 'us', name: 'Agent Teams', highlight: true }, { key: 'us', name: 'Agent Teams', highlight: true },
{ key: 'vibeKanban', name: 'Vibe Kanban' }, { key: 'gastown', name: 'Gastown' },
{ key: 'aperant', name: 'Aperant' }, { key: 'paperclip', name: 'Paperclip' },
{ key: 'cursor', name: 'Cursor' }, { key: 'cursor', name: 'Cursor' },
{ key: 'claudeCli', name: 'Claude Code CLI' }, { key: 'claudeCli', name: 'Claude Code CLI' },
] ]
@ -197,7 +213,7 @@ function getStatusIcon(status: string): string {
case 'yes': return '\u2713' case 'yes': return '\u2713'
case 'no': return '\u2717' case 'no': return '\u2717'
case 'partial': return '\u25D2' case 'partial': return '\u25D2'
case 'na': return '\u2014' case 'na': return 'N/A'
case 'free': return 'Free' case 'free': return 'Free'
case 'soon': return '\uD83D\uDCC5' case 'soon': return '\uD83D\uDCC5'
default: return '' default: return ''

View file

@ -83,6 +83,8 @@
"flexAutonomy": "استقلالية مرنة", "flexAutonomy": "استقلالية مرنة",
"worktree": "عزل Git worktree", "worktree": "عزل Git worktree",
"multiAgent": "خلفية متعددة الوكلاء", "multiAgent": "خلفية متعددة الوكلاء",
"orgGovernance": "الهيكل التنظيمي / الحوكمة",
"budgetControls": "ضوابط الميزانية",
"price": "السعر" "price": "السعر"
} }
}, },

View file

@ -83,6 +83,8 @@
"flexAutonomy": "Flexible Autonomie", "flexAutonomy": "Flexible Autonomie",
"worktree": "Git-Worktree-Isolation", "worktree": "Git-Worktree-Isolation",
"multiAgent": "Multi-Agenten-Backend", "multiAgent": "Multi-Agenten-Backend",
"orgGovernance": "Organigramm / Governance",
"budgetControls": "Budgetkontrollen",
"price": "Preis" "price": "Preis"
} }
}, },

View file

@ -83,6 +83,8 @@
"flexAutonomy": "Flexible autonomy", "flexAutonomy": "Flexible autonomy",
"worktree": "Git worktree isolation", "worktree": "Git worktree isolation",
"multiAgent": "Multi-agent backend", "multiAgent": "Multi-agent backend",
"orgGovernance": "Org chart / governance",
"budgetControls": "Budget controls",
"price": "Price" "price": "Price"
} }
}, },

View file

@ -83,6 +83,8 @@
"flexAutonomy": "Autonomía flexible", "flexAutonomy": "Autonomía flexible",
"worktree": "Aislamiento Git worktree", "worktree": "Aislamiento Git worktree",
"multiAgent": "Backend multi-agente", "multiAgent": "Backend multi-agente",
"orgGovernance": "Organigrama / gobernanza",
"budgetControls": "Controles de presupuesto",
"price": "Precio" "price": "Precio"
} }
}, },

View file

@ -83,6 +83,8 @@
"flexAutonomy": "Autonomie flexible", "flexAutonomy": "Autonomie flexible",
"worktree": "Isolation Git worktree", "worktree": "Isolation Git worktree",
"multiAgent": "Backend multi-agents", "multiAgent": "Backend multi-agents",
"orgGovernance": "Organigramme / gouvernance",
"budgetControls": "Contrôle budgétaire",
"price": "Prix" "price": "Prix"
} }
}, },

View file

@ -83,6 +83,8 @@
"flexAutonomy": "लचीली स्वायत्तता", "flexAutonomy": "लचीली स्वायत्तता",
"worktree": "Git worktree आइसोलेशन", "worktree": "Git worktree आइसोलेशन",
"multiAgent": "मल्टी-एजेंट बैकएंड", "multiAgent": "मल्टी-एजेंट बैकएंड",
"orgGovernance": "ऑर्ग चार्ट / गवर्नेंस",
"budgetControls": "बजट नियंत्रण",
"price": "कीमत" "price": "कीमत"
} }
}, },

View file

@ -83,6 +83,8 @@
"flexAutonomy": "柔軟な自律性", "flexAutonomy": "柔軟な自律性",
"worktree": "Git worktree分離", "worktree": "Git worktree分離",
"multiAgent": "マルチエージェントバックエンド", "multiAgent": "マルチエージェントバックエンド",
"orgGovernance": "組織図 / ガバナンス",
"budgetControls": "予算管理",
"price": "価格" "price": "価格"
} }
}, },

View file

@ -83,6 +83,8 @@
"flexAutonomy": "Autonomia flexível", "flexAutonomy": "Autonomia flexível",
"worktree": "Isolamento Git worktree", "worktree": "Isolamento Git worktree",
"multiAgent": "Backend multi-agente", "multiAgent": "Backend multi-agente",
"orgGovernance": "Organograma / governança",
"budgetControls": "Controles de orçamento",
"price": "Preço" "price": "Preço"
} }
}, },

View file

@ -83,6 +83,8 @@
"flexAutonomy": "Гибкая автономность", "flexAutonomy": "Гибкая автономность",
"worktree": "Изоляция Git worktree", "worktree": "Изоляция Git worktree",
"multiAgent": "Мультиагентный бэкенд", "multiAgent": "Мультиагентный бэкенд",
"orgGovernance": "Оргструктура / управление",
"budgetControls": "Бюджетные лимиты",
"price": "Цена" "price": "Цена"
} }
}, },

View file

@ -83,6 +83,8 @@
"flexAutonomy": "灵活自主", "flexAutonomy": "灵活自主",
"worktree": "Git worktree 隔离", "worktree": "Git worktree 隔离",
"multiAgent": "多智能体后端", "multiAgent": "多智能体后端",
"orgGovernance": "组织架构 / 治理",
"budgetControls": "预算控制",
"price": "价格" "price": "价格"
} }
}, },

View file

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

View file

@ -21,9 +21,38 @@ import { TeamGraphAdapter } from '../adapters/TeamGraphAdapter';
import type { TeamGraphData } from '../adapters/TeamGraphAdapter'; import type { TeamGraphData } from '../adapters/TeamGraphAdapter';
import type { GraphDataPort } from '@claude-teams/agent-graph'; import type { GraphDataPort } from '@claude-teams/agent-graph';
import type { InboxMessage, ResolvedTeamMember, ToolApprovalRequest } from '@shared/types/team';
export function useTeamGraphAdapter(teamName: string): GraphDataPort { interface UseTeamGraphAdapterOptions {
active?: boolean;
}
const EMPTY_MEMBERS: ResolvedTeamMember[] = [];
const EMPTY_MESSAGES: InboxMessage[] = [];
const EMPTY_PENDING_APPROVALS: ToolApprovalRequest[] = [];
const EMPTY_PENDING_APPROVAL_AGENTS = new Set<string>();
const EMPTY_COMMENT_READ_STATE: Record<string, unknown> = {};
function getEmptyCommentReadState(): Record<string, unknown> {
return EMPTY_COMMENT_READ_STATE;
}
function subscribeNoop(): () => void {
return () => undefined;
}
function emptyGraphData(teamName: string): GraphDataPort {
return { nodes: [], edges: [], particles: [], teamName, isAlive: false };
}
export function useTeamGraphAdapter(
teamName: string,
options?: UseTeamGraphAdapterOptions
): GraphDataPort {
const isActive = options?.active ?? true;
const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create()); const adapterRef = useRef<TeamGraphAdapter>(TeamGraphAdapter.create());
const inactiveGraphData = useMemo(() => emptyGraphData(teamName), [teamName]);
const lastActiveGraphDataRef = useRef<GraphDataPort>(inactiveGraphData);
const { const {
teamSnapshot, teamSnapshot,
@ -45,27 +74,32 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
ensureTeamGraphSlotAssignments, ensureTeamGraphSlotAssignments,
} = useStore( } = useStore(
useShallow((s) => ({ useShallow((s) => ({
teamSnapshot: selectTeamDataForName(s, teamName), teamSnapshot: isActive ? selectTeamDataForName(s, teamName) : null,
members: selectResolvedMembersForTeamName(s, teamName), members: isActive ? selectResolvedMembersForTeamName(s, teamName) : EMPTY_MEMBERS,
messages: selectTeamMessages(s, teamName), messages: isActive ? selectTeamMessages(s, teamName) : EMPTY_MESSAGES,
spawnStatuses: teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined, spawnStatuses: isActive && teamName ? s.memberSpawnStatusesByTeam[teamName] : undefined,
leadActivity: teamName ? s.leadActivityByTeam[teamName] : undefined, leadActivity: isActive && teamName ? s.leadActivityByTeam[teamName] : undefined,
leadContext: teamName ? s.leadContextByTeam[teamName] : undefined, leadContext: isActive && teamName ? s.leadContextByTeam[teamName] : undefined,
pendingApprovals: s.pendingApprovals, pendingApprovals: isActive ? s.pendingApprovals : EMPTY_PENDING_APPROVALS,
activeTools: teamName ? s.activeToolsByTeam[teamName] : undefined, activeTools: isActive && teamName ? s.activeToolsByTeam[teamName] : undefined,
finishedVisible: teamName ? s.finishedVisibleByTeam[teamName] : undefined, finishedVisible: isActive && teamName ? s.finishedVisibleByTeam[teamName] : undefined,
toolHistory: teamName ? s.toolHistoryByTeam[teamName] : undefined, toolHistory: isActive && teamName ? s.toolHistoryByTeam[teamName] : undefined,
provisioningProgress: teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null, provisioningProgress:
memberSpawnSnapshot: teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined, isActive && teamName ? getCurrentProvisioningProgressForTeam(s, teamName) : null,
graphLayoutMode: teamName ? s.graphLayoutModeByTeam[teamName] : undefined, memberSpawnSnapshot:
gridOwnerOrder: teamName ? s.gridOwnerOrderByTeam[teamName] : undefined, isActive && teamName ? s.memberSpawnSnapshotsByTeam[teamName] : undefined,
slotAssignments: teamName ? s.slotAssignmentsByTeam[teamName] : undefined, graphLayoutMode: isActive && teamName ? s.graphLayoutModeByTeam[teamName] : undefined,
graphLayoutSession: teamName ? s.graphLayoutSessionByTeam[teamName] : undefined, gridOwnerOrder: isActive && teamName ? s.gridOwnerOrderByTeam[teamName] : undefined,
slotAssignments: isActive && teamName ? s.slotAssignmentsByTeam[teamName] : undefined,
graphLayoutSession: isActive && teamName ? s.graphLayoutSessionByTeam[teamName] : undefined,
ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments, ensureTeamGraphSlotAssignments: s.ensureTeamGraphSlotAssignments,
})) }))
); );
const pendingApprovalAgents = useMemo(() => { const pendingApprovalAgents = useMemo(() => {
if (!isActive) {
return EMPTY_PENDING_APPROVAL_AGENTS;
}
const agents = new Set<string>(); const agents = new Set<string>();
for (const a of pendingApprovals) { for (const a of pendingApprovals) {
if (a.teamName === teamName) { if (a.teamName === teamName) {
@ -73,7 +107,7 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
} }
} }
return agents; return agents;
}, [pendingApprovals, teamName]); }, [isActive, pendingApprovals, teamName]);
const teamData = useMemo<TeamGraphData | null>(() => { const teamData = useMemo<TeamGraphData | null>(() => {
if (!teamSnapshot) { if (!teamSnapshot) {
@ -86,7 +120,10 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
}; };
}, [members, messages, teamSnapshot]); }, [members, messages, teamSnapshot]);
const commentReadState = useSyncExternalStore(subscribe, getSnapshot); const commentReadState = useSyncExternalStore(
isActive ? subscribe : subscribeNoop,
isActive ? getSnapshot : getEmptyCommentReadState
);
const effectiveSlotAssignments = useMemo(() => { const effectiveSlotAssignments = useMemo(() => {
if (!teamData) { if (!teamData) {
@ -127,32 +164,17 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
}, [graphLayoutSession, slotAssignments, teamData]); }, [graphLayoutSession, slotAssignments, teamData]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!teamName || !teamData) { if (!isActive || !teamName || !teamData) {
return; return;
} }
ensureTeamGraphSlotAssignments(teamName, teamData.members, teamData.config.members ?? []); ensureTeamGraphSlotAssignments(teamName, teamData.members, teamData.config.members ?? []);
}, [ensureTeamGraphSlotAssignments, teamData, teamName]); }, [ensureTeamGraphSlotAssignments, isActive, teamData, teamName]);
return useMemo( const activeGraphData = useMemo(() => {
() => if (!isActive) {
adapterRef.current.adapt( return null;
teamData, }
teamName, return adapterRef.current.adapt(
spawnStatuses,
leadActivity,
leadContext,
pendingApprovalAgents,
activeTools,
finishedVisible,
toolHistory,
commentReadState,
provisioningProgress,
memberSpawnSnapshot,
effectiveSlotAssignments,
graphLayoutMode ?? 'radial',
gridOwnerOrder
),
[
teamData, teamData,
teamName, teamName,
spawnStatuses, spawnStatuses,
@ -166,8 +188,38 @@ export function useTeamGraphAdapter(teamName: string): GraphDataPort {
provisioningProgress, provisioningProgress,
memberSpawnSnapshot, memberSpawnSnapshot,
effectiveSlotAssignments, effectiveSlotAssignments,
graphLayoutMode, graphLayoutMode ?? 'radial',
gridOwnerOrder, gridOwnerOrder
] );
); }, [
isActive,
teamData,
teamName,
spawnStatuses,
leadActivity,
leadContext,
pendingApprovalAgents,
activeTools,
finishedVisible,
toolHistory,
commentReadState,
provisioningProgress,
memberSpawnSnapshot,
effectiveSlotAssignments,
graphLayoutMode,
gridOwnerOrder,
]);
useLayoutEffect(() => {
if (activeGraphData) {
lastActiveGraphDataRef.current = activeGraphData;
}
}, [activeGraphData]);
if (!isActive) {
const lastActiveGraphData = lastActiveGraphDataRef.current;
return lastActiveGraphData.teamName === teamName ? lastActiveGraphData : inactiveGraphData;
}
return activeGraphData ?? inactiveGraphData;
} }

View file

@ -49,7 +49,7 @@ export const TeamGraphTab = ({
isActive = true, isActive = true,
isPaneFocused = false, isPaneFocused = false,
}: TeamGraphTabProps): React.JSX.Element => { }: TeamGraphTabProps): React.JSX.Element => {
const graphData = useTeamGraphAdapter(teamName); const graphData = useTeamGraphAdapter(teamName, { active: isActive });
const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } = const { openTeamPage, commitOwnerSlotDrop, commitOwnerGridOrderDrop, setLayoutMode } =
useTeamGraphSurfaceActions(teamName); useTeamGraphSurfaceActions(teamName);
const [fullscreen, setFullscreen] = useState(false); const [fullscreen, setFullscreen] = useState(false);

View file

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

View file

@ -1,16 +1,48 @@
import { MemberWorkSyncReconciler } from './MemberWorkSyncReconciler'; import { decideMemberWorkSyncStatus } from '../domain';
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts'; import type { MemberWorkSyncStatus, MemberWorkSyncStatusRequest } from '../../contracts';
import type { MemberWorkSyncUseCaseDeps } from './ports'; import type { MemberWorkSyncUseCaseDeps } from './ports';
export class MemberWorkSyncDiagnosticsReader { export class MemberWorkSyncDiagnosticsReader {
private readonly reconciler: MemberWorkSyncReconciler; constructor(private readonly deps: MemberWorkSyncUseCaseDeps) {}
constructor(deps: MemberWorkSyncUseCaseDeps) {
this.reconciler = new MemberWorkSyncReconciler(deps);
}
async execute(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> { async execute(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> {
return this.reconciler.execute(request); const stored = await this.deps.statusStore.read(request);
if (stored) {
return stored;
}
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;
const decision = decideMemberWorkSyncStatus({
agenda,
nowIso,
inactive: source.inactive || !teamActive,
});
return {
teamName: agenda.teamName,
memberName: agenda.memberName,
state: decision.state,
agenda,
shadow: {
reconciledBy: 'request',
wouldNudge: false,
fingerprintChanged: false,
},
evaluatedAt: nowIso,
diagnostics: [
...agenda.diagnostics,
...(!teamActive ? ['team_runtime_inactive'] : []),
...decision.diagnostics,
'status_snapshot_not_persisted',
],
...(source.providerId ? { providerId: source.providerId } : {}),
};
} }
} }

View file

@ -1,4 +1,6 @@
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit'; import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
import { finalizeMemberWorkSyncAgenda } from './MemberWorkSyncReconciler';
import { decideMemberWorkSyncStatus } from '../domain';
import type { MemberWorkSyncOutboxItem } from '../../contracts'; import type { MemberWorkSyncOutboxItem } from '../../contracts';
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports'; import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
@ -188,21 +190,41 @@ export class MemberWorkSyncNudgeDispatcher {
): Promise< ): Promise<
{ ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string } { ok: true } | { ok: false; reason: string; retryable: boolean; nextAttemptAt?: string }
> { > {
if (this.deps.lifecycle && !(await this.deps.lifecycle.isTeamActive(item.teamName))) { const teamActive = this.deps.lifecycle
? await this.deps.lifecycle.isTeamActive(item.teamName)
: true;
if (!teamActive) {
return { ok: false, reason: 'team_inactive', retryable: false }; return { ok: false, reason: 'team_inactive', retryable: false };
} }
const status = await this.deps.statusStore.read({ const previous = await this.deps.statusStore.read({
teamName: item.teamName, teamName: item.teamName,
memberName: item.memberName, memberName: item.memberName,
}); });
if (!status) { if (!previous) {
return { ok: false, reason: 'status_missing', retryable: false }; return { ok: false, reason: 'status_missing', retryable: false };
} }
let source;
try {
source = await this.deps.agendaSource.loadAgenda({
teamName: item.teamName,
memberName: item.memberName,
});
} catch (error) {
return { ok: false, reason: `agenda_revalidation_failed:${String(error)}`, retryable: true };
}
const agenda = finalizeMemberWorkSyncAgenda(this.deps, source);
const decision = decideMemberWorkSyncStatus({
agenda,
latestAcceptedReport: previous.report?.accepted ? previous.report : null,
nowIso,
inactive: source.inactive || !teamActive,
});
if ( if (
status.state !== 'needs_sync' || decision.state !== 'needs_sync' ||
status.shadow?.wouldNudge !== true || agenda.items.length === 0 ||
status.agenda.fingerprint !== item.agendaFingerprint agenda.fingerprint !== item.agendaFingerprint
) { ) {
return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false }; return { ok: false, reason: 'status_no_longer_matches_outbox', retryable: false };
} }

View file

@ -58,6 +58,22 @@ function getActiveMemberNames(members: MemberWorkSyncMemberLike[]): Set<string>
); );
} }
function isLeadLike(member: MemberWorkSyncMemberLike): boolean {
const name = normalizeMemberName(member.name);
const agentType = typeof member.agentType === 'string' ? member.agentType : '';
return (
name === 'team-lead' ||
agentType === 'team-lead' ||
agentType === 'lead' ||
agentType === 'orchestrator'
);
}
function getActiveLeadName(members: MemberWorkSyncMemberLike[]): string | null {
const lead = members.find((member) => !member.removedAt && isLeadLike(member));
return lead ? normalizeMemberName(lead.name) : null;
}
function buildBaseItem( function buildBaseItem(
task: MemberWorkSyncTaskLike, task: MemberWorkSyncTaskLike,
memberName: string memberName: string
@ -70,12 +86,23 @@ function buildBaseItem(
}; };
} }
function taskReferenceKeys(task: Pick<MemberWorkSyncTaskLike, 'id' | 'displayId'>): string[] {
const keys = [task.id, task.displayId]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))];
}
export function buildActionableWorkAgenda( export function buildActionableWorkAgenda(
input: BuildActionableWorkAgendaInput input: BuildActionableWorkAgendaInput
): MemberWorkSyncAgenda { ): MemberWorkSyncAgenda {
const memberName = normalizeMemberName(input.memberName); const memberName = normalizeMemberName(input.memberName);
const diagnostics: string[] = []; const diagnostics: string[] = [];
const activeMemberNames = getActiveMemberNames(input.members); const activeMemberNames = getActiveMemberNames(input.members);
const activeLeadName = getActiveLeadName(input.members);
const tasksByReference = new Map(
input.tasks.flatMap((task) => taskReferenceKeys(task).map((key) => [key, task] as const))
);
if (!memberName || isReservedMemberName(memberName)) { if (!memberName || isReservedMemberName(memberName)) {
diagnostics.push('member_invalid_or_reserved'); diagnostics.push('member_invalid_or_reserved');
@ -95,6 +122,57 @@ export function buildActionableWorkAgenda(
const base = buildBaseItem(task, memberName); const base = buildBaseItem(task, memberName);
const blockedBy = [...(task.blockedBy ?? [])].filter(Boolean).sort(); const blockedBy = [...(task.blockedBy ?? [])].filter(Boolean).sort();
const blocks = [...(task.blocks ?? [])].filter(Boolean).sort(); const blocks = [...(task.blocks ?? [])].filter(Boolean).sort();
const brokenDependencyIds: string[] = [];
const waitingDependencyIds: string[] = [];
for (const dependencyId of blockedBy) {
const dependency = tasksByReference.get(dependencyId) ?? null;
if (!dependency || dependency.status === 'deleted' || dependency.deletedAt) {
brokenDependencyIds.push(dependencyId);
} else if (dependency.status !== 'completed') {
waitingDependencyIds.push(dependencyId);
}
}
if (
activeLeadName &&
sameMemberName(activeLeadName, memberName) &&
task.needsClarification === 'lead'
) {
items.push({
...base,
kind: 'clarification',
priority: 'needs_clarification',
reason: 'task_needs_lead_clarification',
evidence: {
status: task.status,
...(owner ? { owner } : {}),
...(task.reviewState ? { reviewState: task.reviewState } : {}),
needsClarification: 'lead',
},
});
continue;
}
if (
activeLeadName &&
sameMemberName(activeLeadName, memberName) &&
brokenDependencyIds.length > 0
) {
items.push({
...base,
kind: 'blocked_dependency',
priority: 'blocked',
reason: 'task_has_broken_dependency',
evidence: {
status: task.status,
...(owner ? { owner } : {}),
...(task.reviewState ? { reviewState: task.reviewState } : {}),
blockedByTaskIds: brokenDependencyIds,
...(blocks.length > 0 ? { blockerTaskIds: blocks } : {}),
},
});
continue;
}
const reviewOwner = resolveCurrentReviewOwner({ const reviewOwner = resolveCurrentReviewOwner({
reviewState: task.reviewState, reviewState: task.reviewState,
@ -126,44 +204,28 @@ export function buildActionableWorkAgenda(
} }
if (task.needsClarification === 'lead' || task.needsClarification === 'user') { 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; continue;
} }
if (blockedBy.length > 0) { if (waitingDependencyIds.length > 0 || brokenDependencyIds.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; continue;
} }
if (task.status === 'pending' || task.status === 'in_progress') { if (
task.status === 'pending' ||
task.status === 'in_progress' ||
task.reviewState === 'needsFix'
) {
items.push({ items.push({
...base, ...base,
kind: 'work', kind: 'work',
priority: 'normal', priority: 'normal',
reason: task.status === 'pending' ? 'owned_pending_task' : 'owned_in_progress_task', reason:
task.reviewState === 'needsFix'
? 'review_changes_requested'
: task.status === 'pending'
? 'owned_pending_task'
: 'owned_in_progress_task',
evidence: { evidence: {
status: task.status, status: task.status,
owner: memberName, owner: memberName,

View file

@ -38,7 +38,9 @@ export function buildMemberWorkSyncNudgeId(input: {
].join(':'); ].join(':');
} }
export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): MemberWorkSyncNudgePayload { export function buildMemberWorkSyncNudgePayload(
status: MemberWorkSyncStatus
): MemberWorkSyncNudgePayload {
const taskRefs = status.agenda.items.map((item) => ({ const taskRefs = status.agenda.items.map((item) => ({
teamName: status.teamName, teamName: status.teamName,
taskId: item.taskId, taskId: item.taskId,
@ -48,6 +50,7 @@ export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): M
.slice(0, 3) .slice(0, 3)
.map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`) .map((item) => `${item.displayId ?? item.taskId.slice(0, 8)} ${item.subject}`)
.join('; '); .join('; ');
const taskIds = status.agenda.items.map((item) => item.taskId).filter(Boolean);
return { return {
from: 'system', from: 'system',
@ -59,7 +62,13 @@ export function buildMemberWorkSyncNudgePayload(status: MemberWorkSyncStatus): M
text: [ text: [
'Work sync check: you have current actionable work assigned.', 'Work sync check: you have current actionable work assigned.',
preview ? `Current agenda: ${preview}.` : '', 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.', `Required sync action: call member_work_sync_status with teamName "${status.teamName}" and memberName "${status.memberName}", then call member_work_sync_report with the same teamName/memberName and the returned agendaFingerprint and reportToken.`,
taskIds.length
? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
: '',
`Do not use provider names, runtime names, or team names as memberName; use exactly "${status.memberName}".`,
'If you are still working, report state "still_working"; if you are blocked, report state "blocked" and record the blocker on the task.',
'Continue concrete task work, report a real blocker with task tools, or sync your current fingerprint before going idle.',
'Do not reply only with acknowledgement.', 'Do not reply only with acknowledgement.',
] ]
.filter(Boolean) .filter(Boolean)

View file

@ -38,15 +38,32 @@ function agendaHasBlockedEvidence(
agenda: MemberWorkSyncAgenda, agenda: MemberWorkSyncAgenda,
taskIds: string[] | undefined taskIds: string[] | undefined
): boolean { ): boolean {
const targetIds = new Set((taskIds ?? []).filter(Boolean)); const targetIds = new Set((taskIds ?? []).flatMap(taskReferenceKeys));
return agenda.items.some((item) => { return agenda.items.some((item) => {
if (targetIds.size > 0 && !targetIds.has(item.taskId)) { if (
targetIds.size > 0 &&
!taskReferenceKeys(item).some((reference) => targetIds.has(reference))
) {
return false; return false;
} }
return item.kind === 'blocked_dependency' || item.priority === 'blocked'; return item.kind === 'blocked_dependency' || item.priority === 'blocked';
}); });
} }
function taskReferenceKeys(
task: Pick<MemberWorkSyncAgenda['items'][number], 'taskId' | 'displayId'> | string
): string[] {
const values = typeof task === 'string' ? [task] : [task.taskId, task.displayId];
return [
...new Set(
values
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value))
.flatMap((value) => [value, value.replace(/^#/, '')])
),
];
}
export function validateMemberWorkSyncReport(input: { export function validateMemberWorkSyncReport(input: {
request: MemberWorkSyncReportRequest; request: MemberWorkSyncReportRequest;
agenda: MemberWorkSyncAgenda; agenda: MemberWorkSyncAgenda;
@ -92,9 +109,9 @@ export function validateMemberWorkSyncReport(input: {
}; };
} }
const agendaTaskIds = new Set(input.agenda.items.map((item) => item.taskId)); const agendaTaskIds = new Set(input.agenda.items.flatMap(taskReferenceKeys));
for (const taskId of input.request.taskIds ?? []) { for (const taskId of input.request.taskIds ?? []) {
if (!agendaTaskIds.has(taskId)) { if (!taskReferenceKeys(taskId).some((reference) => agendaTaskIds.has(reference))) {
return { return {
ok: false, ok: false,
code: 'foreign_task_id', code: 'foreign_task_id',

View file

@ -50,6 +50,14 @@ export function resolveCurrentReviewOwner(input: {
return null; return null;
} }
const kanbanReviewer = normalizeMemberName(input.kanbanReviewer);
if (kanbanReviewer) {
return {
reviewer: kanbanReviewer,
historyEventIds: [],
};
}
const latestStarted = [...historyEvents] const latestStarted = [...historyEvents]
.reverse() .reverse()
.find((event) => event.type === 'review_started'); .find((event) => event.type === 'review_started');
@ -58,9 +66,7 @@ export function resolveCurrentReviewOwner(input: {
.find((event) => event.type === 'review_requested'); .find((event) => event.type === 'review_requested');
const reviewer = const reviewer =
normalizeMemberName(latestStarted?.actor) || normalizeMemberName(latestStarted?.actor) || normalizeMemberName(latestRequested?.reviewer);
normalizeMemberName(latestRequested?.reviewer) ||
normalizeMemberName(input.kanbanReviewer);
if (!reviewer) { if (!reviewer) {
return null; return null;

View file

@ -0,0 +1,191 @@
import { isLeadMember } from '@shared/utils/leadDetection';
import { normalizeMemberName, resolveCurrentReviewOwner } from '../../../core/domain';
import type { TeamKanbanManager } from '@main/services/team/TeamKanbanManager';
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import type { TeamTask } from '@shared/types';
export interface MemberWorkSyncTaskImpactResolverDeps {
taskReader: Pick<TeamTaskReader, 'getTasks'>;
kanbanManager: Pick<TeamKanbanManager, 'getState'>;
activeMemberSource: {
loadActiveMemberNames(teamName: string): Promise<string[]>;
};
}
export interface MemberWorkSyncTaskImpactResolverResult {
memberNames: string[];
fallbackTeamWide: boolean;
diagnostics: string[];
}
function isTerminalTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
return task.status === 'completed' || task.status === 'deleted' || Boolean(task.deletedAt);
}
function isDeletedTask(task: Pick<TeamTask, 'status' | 'deletedAt'>): boolean {
return task.status === 'deleted' || Boolean(task.deletedAt);
}
function taskMatchesId(task: TeamTask, taskId: string): boolean {
const normalized = taskId.trim().replace(/^#/, '');
return (
task.id === taskId ||
task.id === normalized ||
task.displayId === taskId ||
task.displayId === normalized ||
task.displayId === `#${normalized}`
);
}
function taskReferenceKeys(task: Pick<TeamTask, 'id' | 'displayId'>): string[] {
const keys = [task.id, task.displayId]
.map((value) => value?.trim())
.filter((value): value is string => Boolean(value));
return [...new Set(keys.flatMap((value) => [value, value.replace(/^#/, '')]))];
}
function findLeadMemberName(activeMembers: string[]): string | null {
return activeMembers.find((memberName) => isLeadMember({ name: memberName })) ?? null;
}
export function extractMemberWorkSyncTaskId(input: {
taskId?: string;
detail?: string;
}): string | null {
const explicit = input.taskId?.trim();
if (explicit) {
return explicit;
}
const detail = input.detail?.trim();
if (!detail || detail.startsWith('.') || !detail.endsWith('.json')) {
return null;
}
const fileName = detail.split(/[\\/]/).filter(Boolean).at(-1);
const taskId = fileName?.replace(/\.json$/i, '').trim();
return taskId && !taskId.startsWith('.') ? taskId : null;
}
export class MemberWorkSyncTaskImpactResolver {
constructor(private readonly deps: MemberWorkSyncTaskImpactResolverDeps) {}
async resolve(input: {
teamName: string;
taskId: string;
}): Promise<MemberWorkSyncTaskImpactResolverResult> {
const taskId = input.taskId.trim();
if (!taskId) {
return {
memberNames: [],
fallbackTeamWide: true,
diagnostics: ['task_id_missing'],
};
}
const [activeMembers, tasks, kanban] = await Promise.all([
this.deps.activeMemberSource.loadActiveMemberNames(input.teamName),
this.deps.taskReader.getTasks(input.teamName),
this.deps.kanbanManager.getState(input.teamName),
]);
const activeByName = new Map(
activeMembers.map((memberName) => [normalizeMemberName(memberName), memberName] as const)
);
const impacted = new Set<string>();
const diagnostics: string[] = [];
const addDiagnostic = (diagnostic: string): void => {
if (!diagnostics.includes(diagnostic)) {
diagnostics.push(diagnostic);
}
};
const addMember = (value: unknown): void => {
const normalized = normalizeMemberName(value);
const activeName = activeByName.get(normalized);
if (activeName) {
impacted.add(activeName);
}
};
const addLead = (): void => {
const leadName = findLeadMemberName(activeMembers);
if (leadName) {
impacted.add(leadName);
} else {
addDiagnostic('lead_member_unavailable');
}
};
const task = tasks.find((candidate) => taskMatchesId(candidate, taskId));
if (!task) {
return {
memberNames: [],
fallbackTeamWide: true,
diagnostics: ['task_not_found'],
};
}
addMember(task.owner);
const reviewOwner = resolveCurrentReviewOwner({
reviewState: task.reviewState,
kanbanReviewer: kanban.tasks[task.id]?.reviewer ?? null,
historyEvents: task.historyEvents,
});
addMember(reviewOwner?.reviewer);
if (!normalizeMemberName(task.owner)) {
addLead();
addDiagnostic('task_owner_missing');
} else if (!activeByName.has(normalizeMemberName(task.owner))) {
addLead();
addDiagnostic('task_owner_inactive');
}
if (task.reviewState === 'review' && !reviewOwner?.reviewer) {
addLead();
addDiagnostic('task_reviewer_missing');
}
if (task.needsClarification === 'lead') {
addLead();
}
const tasksByReference = new Map(
tasks.flatMap((candidate) =>
taskReferenceKeys(candidate).map((key) => [key, candidate] as const)
)
);
const brokenDependencies = (task.blockedBy ?? []).filter((dependencyId) => {
const dependency = tasksByReference.get(dependencyId);
return !dependency || isDeletedTask(dependency);
});
if (brokenDependencies.length > 0) {
addLead();
addDiagnostic('task_has_broken_dependencies');
}
for (const candidate of tasks) {
if (candidate.id === task.id || isTerminalTask(candidate)) {
continue;
}
if (
(candidate.blockedBy ?? []).some(
(dependencyId) => tasksByReference.get(dependencyId) === task
)
) {
addMember(candidate.owner);
if (isDeletedTask(task)) {
addLead();
addDiagnostic('dependent_task_has_deleted_dependency');
}
}
}
return {
memberNames: [...impacted].sort((left, right) => left.localeCompare(right)),
fallbackTeamWide: false,
diagnostics,
};
}
}

View file

@ -2,7 +2,9 @@ import type {
MemberWorkSyncEventQueue, MemberWorkSyncEventQueue,
MemberWorkSyncTriggerReason, MemberWorkSyncTriggerReason,
} from '../../infrastructure/MemberWorkSyncEventQueue'; } from '../../infrastructure/MemberWorkSyncEventQueue';
import type { MemberWorkSyncTaskImpactResolver } from './MemberWorkSyncTaskImpactResolver';
import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types'; import type { TeamChangeEvent, ToolActivityEventPayload } from '@shared/types';
import { extractMemberWorkSyncTaskId } from './MemberWorkSyncTaskImpactResolver';
interface MemberTurnSettledEventPayload { interface MemberTurnSettledEventPayload {
memberName?: string; memberName?: string;
@ -20,8 +22,6 @@ interface MemberWorkSyncMemberStorageMaterializer {
const TEAM_WIDE_REASONS: Partial<Record<TeamChangeEvent['type'], MemberWorkSyncTriggerReason>> = { const TEAM_WIDE_REASONS: Partial<Record<TeamChangeEvent['type'], MemberWorkSyncTriggerReason>> = {
config: 'config_changed', config: 'config_changed',
task: 'task_changed',
'task-log-change': 'runtime_activity',
'log-source-change': 'runtime_activity', 'log-source-change': 'runtime_activity',
process: 'runtime_activity', process: 'runtime_activity',
'lead-activity': 'runtime_activity', 'lead-activity': 'runtime_activity',
@ -63,7 +63,8 @@ export class MemberWorkSyncTeamChangeRouter {
constructor( constructor(
private readonly rosterSource: MemberWorkSyncRosterSource, private readonly rosterSource: MemberWorkSyncRosterSource,
private readonly queue: MemberWorkSyncEventQueue, private readonly queue: MemberWorkSyncEventQueue,
private readonly materializer?: MemberWorkSyncMemberStorageMaterializer private readonly materializer?: MemberWorkSyncMemberStorageMaterializer,
private readonly taskImpactResolver?: MemberWorkSyncTaskImpactResolver
) {} ) {}
async enqueueStartupScan(teamNames: string[]): Promise<void> { async enqueueStartupScan(teamNames: string[]): Promise<void> {
@ -118,6 +119,14 @@ export class MemberWorkSyncTeamChangeRouter {
return; return;
} }
if (event.type === 'task' || event.type === 'task-log-change') {
const triggerReason = event.type === 'task' ? 'task_changed' : 'runtime_activity';
void this.enqueueTaskRelatedMembers(event, triggerReason).catch(() =>
this.enqueueTeam(event.teamName, triggerReason).catch(() => undefined)
);
return;
}
if (event.type === 'inbox' || event.type === 'lead-message') { if (event.type === 'inbox' || event.type === 'lead-message') {
const recipient = parseInboxRecipient(event.detail); const recipient = parseInboxRecipient(event.detail);
if (recipient) { if (recipient) {
@ -152,4 +161,41 @@ export class MemberWorkSyncTeamChangeRouter {
this.queue.enqueue({ teamName, memberName, triggerReason, runAfterMs }); this.queue.enqueue({ teamName, memberName, triggerReason, runAfterMs });
} }
} }
private async enqueueTaskRelatedMembers(
event: TeamChangeEvent,
triggerReason: MemberWorkSyncTriggerReason
): Promise<void> {
const taskId = extractMemberWorkSyncTaskId({
taskId: event.taskId,
detail: event.detail,
});
if (!taskId || !this.taskImpactResolver) {
await this.enqueueTeam(event.teamName, triggerReason);
return;
}
const impact = await this.taskImpactResolver.resolve({
teamName: event.teamName,
taskId,
});
if (impact.fallbackTeamWide) {
await this.enqueueTeam(event.teamName, triggerReason);
return;
}
if (this.materializer) {
await Promise.allSettled(
impact.memberNames.map((memberName) =>
this.materializer?.materializeMember(event.teamName, memberName)
)
);
}
for (const memberName of impact.memberNames) {
this.queue.enqueue({
teamName: event.teamName,
memberName,
triggerReason,
});
}
}
} }

View file

@ -3,6 +3,7 @@ import { createLogger } from '@shared/utils/logger';
import { import {
MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_METRICS,
MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_GET_STATUS,
MEMBER_WORK_SYNC_REFRESH_STATUS,
MEMBER_WORK_SYNC_REPORT, MEMBER_WORK_SYNC_REPORT,
type MemberWorkSyncMetricsRequest, type MemberWorkSyncMetricsRequest,
type MemberWorkSyncReportRequest, type MemberWorkSyncReportRequest,
@ -45,6 +46,18 @@ export function registerMemberWorkSyncIpc(
} }
); );
ipcMain.handle(
MEMBER_WORK_SYNC_REFRESH_STATUS,
async (_event, request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus> => {
try {
return await feature.refreshStatus(request);
} catch (error) {
logger.error('Failed to refresh member work sync status', error);
throw error;
}
}
);
ipcMain.handle( ipcMain.handle(
MEMBER_WORK_SYNC_REPORT, MEMBER_WORK_SYNC_REPORT,
async (_event, request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> => { async (_event, request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult> => {
@ -60,6 +73,7 @@ export function registerMemberWorkSyncIpc(
export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void { export function removeMemberWorkSyncIpc(ipcMain: IpcMain): void {
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS); ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_STATUS);
ipcMain.removeHandler(MEMBER_WORK_SYNC_REFRESH_STATUS);
ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_METRICS); ipcMain.removeHandler(MEMBER_WORK_SYNC_GET_METRICS);
ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT); ipcMain.removeHandler(MEMBER_WORK_SYNC_REPORT);
} }

View file

@ -13,6 +13,7 @@ import {
type RuntimeTurnSettledTargetResolverPort, type RuntimeTurnSettledTargetResolverPort,
} from '../../core/application'; } from '../../core/application';
import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter'; import { MemberWorkSyncTeamChangeRouter } from '../adapters/input/MemberWorkSyncTeamChangeRouter';
import { MemberWorkSyncTaskImpactResolver } from '../adapters/input/MemberWorkSyncTaskImpactResolver';
import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink'; import { TeamInboxMemberWorkSyncNudgeSink } from '../adapters/output/TeamInboxMemberWorkSyncNudgeSink';
import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver'; import { TeamRuntimeTurnSettledTargetResolver } from '../adapters/output/TeamRuntimeTurnSettledTargetResolver';
import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource'; import { TeamTaskAgendaSource } from '../adapters/output/TeamTaskAgendaSource';
@ -53,34 +54,6 @@ import type { TeamMembersMetaStore } from '@main/services/team/TeamMembersMetaSt
import type { TeamTaskReader } from '@main/services/team/TeamTaskReader'; import type { TeamTaskReader } from '@main/services/team/TeamTaskReader';
import type { TeamChangeEvent } from '@shared/types'; 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: { export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: {
teamsBasePath: string; teamsBasePath: string;
provider: RuntimeTurnSettledProvider; provider: RuntimeTurnSettledProvider;
@ -92,6 +65,7 @@ export function buildMemberWorkSyncRuntimeTurnSettledEnvironment(input: {
export interface MemberWorkSyncFeatureFacade { export interface MemberWorkSyncFeatureFacade {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>; getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
refreshStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>; getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>; report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
noteTeamChange(event: TeamChangeEvent): void; noteTeamChange(event: TeamChangeEvent): void;
@ -117,7 +91,6 @@ export function createMemberWorkSyncFeature(deps: {
membersMetaStore: TeamMembersMetaStore; membersMetaStore: TeamMembersMetaStore;
isTeamActive?: (teamName: string) => Promise<boolean> | boolean; isTeamActive?: (teamName: string) => Promise<boolean> | boolean;
listLifecycleActiveTeamNames?: () => Promise<string[]>; listLifecycleActiveTeamNames?: () => Promise<string[]>;
nudgeSideEffectsEnabled?: boolean;
queueQuietWindowMs?: number; queueQuietWindowMs?: number;
runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort; runtimeTurnSettledTargetResolver?: RuntimeTurnSettledTargetResolverPort;
logger?: MemberWorkSyncLoggerPort; logger?: MemberWorkSyncLoggerPort;
@ -166,17 +139,15 @@ export function createMemberWorkSyncFeature(deps: {
const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths); const reportToken = new HmacMemberWorkSyncReportTokenAdapter(storePaths);
const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath); const watchdogCooldown = new TeamTaskStallJournalWorkSyncCooldown(deps.teamsBasePath);
const busySignal = new MemberWorkSyncToolActivityBusySignal(); const busySignal = new MemberWorkSyncToolActivityBusySignal();
const nudgeSideEffectsEnabled = const inboxNudge = new TeamInboxMemberWorkSyncNudgeSink();
deps.nudgeSideEffectsEnabled ?? resolveMemberWorkSyncNudgeSideEffectsEnabled();
const inboxNudge = nudgeSideEffectsEnabled ? new TeamInboxMemberWorkSyncNudgeSink() : null;
const useCaseDeps = { const useCaseDeps = {
clock, clock,
hash, hash,
agendaSource, agendaSource,
statusStore: store, statusStore: store,
reportStore: store, reportStore: store,
...(nudgeSideEffectsEnabled ? { outboxStore: store } : {}), outboxStore: store,
...(inboxNudge ? { inboxNudge } : {}), inboxNudge,
watchdogCooldown, watchdogCooldown,
busySignal, busySignal,
reportToken, reportToken,
@ -193,22 +164,30 @@ export function createMemberWorkSyncFeature(deps: {
const queue = new MemberWorkSyncEventQueue({ const queue = new MemberWorkSyncEventQueue({
reconcile: async (request, context: MemberWorkSyncReconcileContext) => { reconcile: async (request, context: MemberWorkSyncReconcileContext) => {
await reconciler.execute(request, context); await reconciler.execute(request, context);
if (nudgeSideEffectsEnabled) { await nudgeDispatcher.dispatchDue({
await nudgeDispatcher.dispatchDue({ teamNames: [request.teamName],
teamNames: [request.teamName], claimedBy: `member-work-sync:${process.pid}`,
claimedBy: `member-work-sync:${process.pid}`, });
});
}
}, },
isTeamActive: deps.isTeamActive ?? (() => true), isTeamActive: deps.isTeamActive ?? (() => true),
...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}), ...(deps.queueQuietWindowMs != null ? { quietWindowMs: deps.queueQuietWindowMs } : {}),
auditJournal, auditJournal,
logger: deps.logger, logger: deps.logger,
}); });
const router = new MemberWorkSyncTeamChangeRouter(agendaSource, queue, { const taskImpactResolver = new MemberWorkSyncTaskImpactResolver({
materializeMember: (teamName, memberName) => taskReader: deps.taskReader,
storePaths.ensureMemberWorkSyncDir(teamName, memberName), kanbanManager: deps.kanbanManager,
activeMemberSource: agendaSource,
}); });
const router = new MemberWorkSyncTeamChangeRouter(
agendaSource,
queue,
{
materializeMember: (teamName, memberName) =>
storePaths.ensureMemberWorkSyncDir(teamName, memberName),
},
taskImpactResolver
);
const runtimeTurnSettledIngestor = new RuntimeTurnSettledIngestor({ const runtimeTurnSettledIngestor = new RuntimeTurnSettledIngestor({
eventStore: runtimeTurnSettledStore, eventStore: runtimeTurnSettledStore,
normalizer: runtimeTurnSettledNormalizer, normalizer: runtimeTurnSettledNormalizer,
@ -234,23 +213,23 @@ export function createMemberWorkSyncFeature(deps: {
drain: () => runtimeTurnSettledIngestor.drainPending(), drain: () => runtimeTurnSettledIngestor.drainPending(),
logger: deps.logger, logger: deps.logger,
}); });
const nudgeDispatchScheduler = const nudgeDispatchScheduler = deps.listLifecycleActiveTeamNames
nudgeSideEffectsEnabled && deps.listLifecycleActiveTeamNames ? new MemberWorkSyncNudgeDispatchScheduler({
? new MemberWorkSyncNudgeDispatchScheduler({ listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames,
listLifecycleActiveTeamNames: deps.listLifecycleActiveTeamNames, dispatchDue: (teamNames) =>
dispatchDue: (teamNames) => nudgeDispatcher.dispatchDue({
nudgeDispatcher.dispatchDue({ teamNames,
teamNames, claimedBy: `member-work-sync:${process.pid}:scheduled`,
claimedBy: `member-work-sync:${process.pid}:scheduled`, }),
}), logger: deps.logger,
logger: deps.logger, })
}) : null;
: null;
runtimeTurnSettledDrainScheduler.start(); runtimeTurnSettledDrainScheduler.start();
nudgeDispatchScheduler?.start(); nudgeDispatchScheduler?.start();
return { return {
getStatus: (request) => diagnosticsReader.execute(request), getStatus: (request) => diagnosticsReader.execute(request),
refreshStatus: (request) => reconciler.execute(request, { reconciledBy: 'request' }),
getMetrics: (request) => metricsReader.execute(request), getMetrics: (request) => metricsReader.execute(request),
report: (request) => reporter.execute(request), report: (request) => reporter.execute(request),
noteTeamChange: (event) => { noteTeamChange: (event) => {
@ -277,12 +256,10 @@ export function createMemberWorkSyncFeature(deps: {
); );
}, },
dispatchDueNudges: (teamNames) => dispatchDueNudges: (teamNames) =>
nudgeSideEffectsEnabled nudgeDispatcher.dispatchDue({
? nudgeDispatcher.dispatchDue({ teamNames,
teamNames, claimedBy: `member-work-sync:${process.pid}`,
claimedBy: `member-work-sync:${process.pid}`, }),
})
: Promise.resolve(emptyNudgeDispatchSummary()),
buildRuntimeTurnSettledHookSettings: async ({ provider }) => buildRuntimeTurnSettledHookSettings: async ({ provider }) =>
runtimeTurnSettledSpool.buildHookSettings({ provider }), runtimeTurnSettledSpool.buildHookSettings({ provider }),
buildRuntimeTurnSettledEnvironment: async ({ provider }) => buildRuntimeTurnSettledEnvironment: async ({ provider }) =>

View file

@ -7,6 +7,4 @@ export type { MemberWorkSyncFeatureFacade } from './composition/createMemberWork
export { export {
buildMemberWorkSyncRuntimeTurnSettledEnvironment, buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature, createMemberWorkSyncFeature,
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
resolveMemberWorkSyncNudgeSideEffectsEnabled,
} from './composition/createMemberWorkSyncFeature'; } from './composition/createMemberWorkSyncFeature';

View file

@ -139,7 +139,10 @@ export class HmacMemberWorkSyncReportTokenAdapter implements MemberWorkSyncRepor
return existing; return existing;
} }
const next = this.loadOrCreateSecret(teamName); const next = this.loadOrCreateSecret(teamName).catch((error: unknown) => {
this.secretCache.delete(teamName);
throw error;
});
this.secretCache.set(teamName, next); this.secretCache.set(teamName, next);
return next; return next;
} }
@ -153,7 +156,8 @@ export class HmacMemberWorkSyncReportTokenAdapter implements MemberWorkSyncRepor
} }
} catch (error) { } catch (error) {
if ((error as NodeJS.ErrnoException).code !== 'ENOENT') { if ((error as NodeJS.ErrnoException).code !== 'ENOENT') {
throw error; // A corrupt token secret only affects short-lived proof tokens. Regenerate it so
// member work sync can recover without requiring an app restart.
} }
} }

View file

@ -24,20 +24,57 @@ export interface MemberWorkSyncQueueDiagnostics {
reconciled: number; reconciled: number;
dropped: number; dropped: number;
failed: number; failed: number;
nextRunAt?: string;
oldestQueuedAgeMs?: number;
oldestRunningAgeMs?: number;
queuedItems: MemberWorkSyncQueuedItemDiagnostics[];
runningItems: MemberWorkSyncRunningItemDiagnostics[];
}
export interface MemberWorkSyncQueuedItemDiagnostics {
teamName: string;
memberName: string;
firstQueuedAt: string;
lastQueuedAt: string;
runAt: string;
maxRunAt: string;
triggerReasons: MemberWorkSyncTriggerReason[];
triggerReasonCounts: Partial<Record<MemberWorkSyncTriggerReason, number>>;
}
export interface MemberWorkSyncRunningItemDiagnostics {
teamName: string;
memberName: string;
startedAt: string;
ageMs: number;
rerunRequested: boolean;
triggerReasons: MemberWorkSyncTriggerReason[];
} }
interface QueueItem { interface QueueItem {
teamName: string; teamName: string;
memberName: string; memberName: string;
firstQueuedAt: number;
lastQueuedAt: number;
runAt: number; runAt: number;
maxRunAt: number;
triggerReasons: Set<MemberWorkSyncTriggerReason>; triggerReasons: Set<MemberWorkSyncTriggerReason>;
triggerReasonCounts: Map<MemberWorkSyncTriggerReason, number>;
} }
interface RunningItem { interface RunningItem {
teamName: string;
memberName: string;
startedAt: number;
rerunRequested: boolean; rerunRequested: boolean;
triggerReasons: Set<MemberWorkSyncTriggerReason>; triggerReasons: Set<MemberWorkSyncTriggerReason>;
} }
interface TriggerTimingPolicy {
runAfterMs: number;
maxCoalesceWaitMs: number;
}
export interface MemberWorkSyncEventQueueDeps { export interface MemberWorkSyncEventQueueDeps {
reconcile( reconcile(
input: { teamName: string; memberName: string }, input: { teamName: string; memberName: string },
@ -45,6 +82,7 @@ export interface MemberWorkSyncEventQueueDeps {
): Promise<void>; ): Promise<void>;
isTeamActive(teamName: string): Promise<boolean> | boolean; isTeamActive(teamName: string): Promise<boolean> | boolean;
quietWindowMs?: number; quietWindowMs?: number;
triggerTiming?: Partial<Record<MemberWorkSyncTriggerReason, Partial<TriggerTimingPolicy>>>;
concurrency?: number; concurrency?: number;
now?: () => number; now?: () => number;
nowIso?: () => string; nowIso?: () => string;
@ -85,6 +123,29 @@ export class MemberWorkSyncEventQueue {
this.nowIso = deps.nowIso ?? (() => new Date().toISOString()); this.nowIso = deps.nowIso ?? (() => new Date().toISOString());
} }
private resolveTimingPolicy(
triggerReason: MemberWorkSyncTriggerReason,
explicitRunAfterMs?: number
): TriggerTimingPolicy {
const custom = this.deps.triggerTiming?.[triggerReason];
const quietWindowFallback =
this.deps.quietWindowMs != null && triggerReason !== 'manual_refresh';
const runAfterMs = Math.max(
0,
explicitRunAfterMs ??
custom?.runAfterMs ??
(quietWindowFallback ? this.quietWindowMs : defaultRunAfterMs(triggerReason))
);
const maxCoalesceWaitMs = Math.max(
runAfterMs,
custom?.maxCoalesceWaitMs ??
(quietWindowFallback
? Math.max(this.quietWindowMs, this.quietWindowMs * 5)
: defaultMaxCoalesceWaitMs(triggerReason))
);
return { runAfterMs, maxCoalesceWaitMs };
}
enqueue(input: { enqueue(input: {
teamName: string; teamName: string;
memberName: string; memberName: string;
@ -103,7 +164,9 @@ export class MemberWorkSyncEventQueue {
} }
const key = keyOf(teamName, memberName); const key = keyOf(teamName, memberName);
const runAt = this.now() + (input.runAfterMs ?? this.quietWindowMs); const now = this.now();
const timing = this.resolveTimingPolicy(input.triggerReason, input.runAfterMs);
const runAt = now + timing.runAfterMs;
const running = this.running.get(key); const running = this.running.get(key);
if (running) { if (running) {
running.rerunRequested = true; running.rerunRequested = true;
@ -122,7 +185,20 @@ export class MemberWorkSyncEventQueue {
const existing = this.items.get(key); const existing = this.items.get(key);
if (existing) { if (existing) {
existing.triggerReasons.add(input.triggerReason); existing.triggerReasons.add(input.triggerReason);
existing.runAt = Math.max(existing.runAt, runAt); existing.lastQueuedAt = now;
existing.maxRunAt = Math.max(
existing.maxRunAt,
existing.firstQueuedAt + timing.maxCoalesceWaitMs
);
const preserveEarlierRun =
existing.runAt <= now ||
existing.triggerReasons.has('manual_refresh') ||
input.triggerReason === 'manual_refresh' ||
runAt < existing.runAt;
existing.runAt = preserveEarlierRun
? Math.min(existing.runAt, runAt)
: Math.min(Math.max(existing.runAt, runAt), existing.maxRunAt);
incrementReasonCount(existing.triggerReasonCounts, input.triggerReason);
this.counters.coalesced += 1; this.counters.coalesced += 1;
this.appendAudit({ this.appendAudit({
teamName, teamName,
@ -138,8 +214,12 @@ export class MemberWorkSyncEventQueue {
this.items.set(key, { this.items.set(key, {
teamName, teamName,
memberName, memberName,
firstQueuedAt: now,
lastQueuedAt: now,
runAt, runAt,
maxRunAt: now + timing.maxCoalesceWaitMs,
triggerReasons: new Set([input.triggerReason]), triggerReasons: new Set([input.triggerReason]),
triggerReasonCounts: new Map([[input.triggerReason, 1]]),
}); });
this.counters.enqueued += 1; this.counters.enqueued += 1;
this.appendAudit({ this.appendAudit({
@ -163,10 +243,50 @@ export class MemberWorkSyncEventQueue {
} }
getDiagnostics(): MemberWorkSyncQueueDiagnostics { getDiagnostics(): MemberWorkSyncQueueDiagnostics {
const now = this.now();
const queuedItems = [...this.items.values()]
.sort((left, right) => left.runAt - right.runAt)
.map((item) => ({
teamName: item.teamName,
memberName: item.memberName,
firstQueuedAt: new Date(item.firstQueuedAt).toISOString(),
lastQueuedAt: new Date(item.lastQueuedAt).toISOString(),
runAt: new Date(item.runAt).toISOString(),
maxRunAt: new Date(item.maxRunAt).toISOString(),
triggerReasons: [...item.triggerReasons].sort(),
triggerReasonCounts: Object.fromEntries(item.triggerReasonCounts),
}));
const runningItems = [...this.running.values()]
.sort((left, right) => left.startedAt - right.startedAt)
.map((item) => ({
teamName: item.teamName,
memberName: item.memberName,
startedAt: new Date(item.startedAt).toISOString(),
ageMs: Math.max(0, now - item.startedAt),
rerunRequested: item.rerunRequested,
triggerReasons: [...item.triggerReasons].sort(),
}));
const oldestQueuedAt =
queuedItems.length > 0
? Math.min(...[...this.items.values()].map((item) => item.firstQueuedAt))
: null;
const oldestRunningAt =
runningItems.length > 0
? Math.min(...[...this.running.values()].map((item) => item.startedAt))
: null;
const nextRunAt =
this.items.size > 0 ? Math.min(...[...this.items.values()].map((item) => item.runAt)) : null;
return { return {
queued: this.items.size, queued: this.items.size,
running: this.running.size, running: this.running.size,
...this.counters, ...this.counters,
...(nextRunAt != null ? { nextRunAt: new Date(nextRunAt).toISOString() } : {}),
...(oldestQueuedAt != null ? { oldestQueuedAgeMs: Math.max(0, now - oldestQueuedAt) } : {}),
...(oldestRunningAt != null
? { oldestRunningAgeMs: Math.max(0, now - oldestRunningAt) }
: {}),
queuedItems,
runningItems,
}; };
} }
@ -226,6 +346,9 @@ export class MemberWorkSyncEventQueue {
private runItem(key: string, item: QueueItem): void { private runItem(key: string, item: QueueItem): void {
const running: RunningItem = { const running: RunningItem = {
teamName: item.teamName,
memberName: item.memberName,
startedAt: this.now(),
rerunRequested: false, rerunRequested: false,
triggerReasons: new Set(item.triggerReasons), triggerReasons: new Set(item.triggerReasons),
}; };
@ -244,13 +367,7 @@ export class MemberWorkSyncEventQueue {
this.running.delete(key); this.running.delete(key);
this.inFlight.delete(promise); this.inFlight.delete(promise);
if (running.rerunRequested && !this.stopped) { if (running.rerunRequested && !this.stopped) {
for (const reason of running.triggerReasons) { this.enqueueFollowUp(item, running);
this.enqueue({
teamName: item.teamName,
memberName: item.memberName,
triggerReason: reason,
});
}
} }
this.pump(); this.pump();
}); });
@ -258,6 +375,31 @@ export class MemberWorkSyncEventQueue {
this.inFlight.add(promise); this.inFlight.add(promise);
} }
private enqueueFollowUp(item: QueueItem, running: RunningItem): void {
const reasons = [...running.triggerReasons].sort();
const primaryReason =
reasons.find((reason) => reason === 'manual_refresh') ??
reasons.find((reason) => reason === 'turn_settled' || reason === 'tool_finished') ??
reasons[0] ??
'manual_refresh';
this.enqueue({
teamName: item.teamName,
memberName: item.memberName,
triggerReason: primaryReason,
runAfterMs: Math.min(this.resolveTimingPolicy(primaryReason).runAfterMs, 5_000),
});
const queued = this.items.get(keyOf(item.teamName, item.memberName));
if (!queued) {
return;
}
for (const reason of reasons) {
queued.triggerReasons.add(reason);
if (reason !== primaryReason) {
incrementReasonCount(queued.triggerReasonCounts, reason);
}
}
}
private async executeItem(_key: string, item: QueueItem, running: RunningItem): Promise<void> { private async executeItem(_key: string, item: QueueItem, running: RunningItem): Promise<void> {
if (!(await this.deps.isTeamActive(item.teamName))) { if (!(await this.deps.isTeamActive(item.teamName))) {
this.counters.dropped += 1; this.counters.dropped += 1;
@ -307,3 +449,46 @@ export class MemberWorkSyncEventQueue {
}); });
} }
} }
function incrementReasonCount(
counts: Map<MemberWorkSyncTriggerReason, number>,
reason: MemberWorkSyncTriggerReason
): void {
counts.set(reason, (counts.get(reason) ?? 0) + 1);
}
function defaultRunAfterMs(reason: MemberWorkSyncTriggerReason): number {
switch (reason) {
case 'manual_refresh':
return 0;
case 'turn_settled':
case 'tool_finished':
return 5_000;
case 'task_changed':
case 'inbox_changed':
case 'runtime_activity':
return 15_000;
case 'startup_scan':
case 'config_changed':
case 'member_spawned':
return 30_000;
}
}
function defaultMaxCoalesceWaitMs(reason: MemberWorkSyncTriggerReason): number {
switch (reason) {
case 'manual_refresh':
return 0;
case 'turn_settled':
case 'tool_finished':
return 30_000;
case 'task_changed':
case 'inbox_changed':
case 'runtime_activity':
return 60_000;
case 'startup_scan':
case 'config_changed':
case 'member_spawned':
return 90_000;
}
}

View file

@ -1,6 +1,7 @@
import { import {
MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_METRICS,
MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_GET_STATUS,
MEMBER_WORK_SYNC_REFRESH_STATUS,
MEMBER_WORK_SYNC_REPORT, MEMBER_WORK_SYNC_REPORT,
type MemberWorkSyncMetricsRequest, type MemberWorkSyncMetricsRequest,
type MemberWorkSyncReportRequest, type MemberWorkSyncReportRequest,
@ -14,6 +15,7 @@ import type { IpcRenderer } from 'electron';
export interface MemberWorkSyncElectronApi { export interface MemberWorkSyncElectronApi {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>; getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
refreshStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>; getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>; report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
} }
@ -21,6 +23,7 @@ export interface MemberWorkSyncElectronApi {
export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi { export function createMemberWorkSyncBridge(ipcRenderer: IpcRenderer): MemberWorkSyncElectronApi {
return { return {
getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request), getStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_STATUS, request),
refreshStatus: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REFRESH_STATUS, request),
getMetrics: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_METRICS, request), getMetrics: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_GET_METRICS, request),
report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request), report: (request) => ipcRenderer.invoke(MEMBER_WORK_SYNC_REPORT, request),
}; };

View file

@ -753,6 +753,34 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
} }
); );
app.get<{ Params: { teamName: string } }>(
'/api/teams/:teamName/member-work-sync/diagnostics',
async (request, reply) => {
try {
const validatedTeamName = validateTeamName(request.params.teamName);
if (!validatedTeamName.valid) {
return reply.status(400).send({ error: validatedTeamName.error });
}
const feature = getMemberWorkSyncFeature(services);
const metrics = await feature.getMetrics({ teamName: validatedTeamName.value! });
return reply.send({
teamName: validatedTeamName.value!,
generatedAt: new Date().toISOString(),
queue: feature.getQueueDiagnostics(),
metrics,
});
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in GET /api/teams/${request.params.teamName}/member-work-sync/diagnostics:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
app.get<{ Params: { teamName: string } }>( app.get<{ Params: { teamName: string } }>(
'/api/teams/:teamName/member-work-sync/metrics', '/api/teams/:teamName/member-work-sync/metrics',
async (request, reply) => { async (request, reply) => {
@ -808,6 +836,36 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
} }
); );
app.post<{ Params: { teamName: string; memberName: string } }>(
'/api/teams/:teamName/member-work-sync/:memberName/refresh',
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).refreshStatus({
teamName: validatedTeamName.value!,
memberName,
})
);
} catch (error) {
if (shouldLogError(error)) {
logger.error(
`Error in POST /api/teams/${request.params.teamName}/member-work-sync/${request.params.memberName}/refresh:`,
getErrorMessage(error)
);
}
return reply.status(getStatusCode(error)).send({ error: getErrorMessage(error) });
}
}
);
app.post<{ Params: { teamName: string }; Body: Record<string, unknown> }>( app.post<{ Params: { teamName: string }; Body: Record<string, unknown> }>(
'/api/teams/:teamName/member-work-sync/report', '/api/teams/:teamName/member-work-sync/report',
async (request, reply) => { async (request, reply) => {
@ -832,7 +890,14 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
.send({ error: 'state must be still_working, blocked, or caught_up' }); .send({ error: 'state must be still_working, blocked, or caught_up' });
} }
const taskIds = Array.isArray(payload.taskIds) const taskIds = Array.isArray(payload.taskIds)
? payload.taskIds.filter((taskId): taskId is string => typeof taskId === 'string') ? [
...new Set(
payload.taskIds
.filter((taskId): taskId is string => typeof taskId === 'string')
.map((taskId) => taskId.trim())
.filter(Boolean)
),
]
: undefined; : undefined;
return reply.send( return reply.send(
await getMemberWorkSyncFeature(services).report({ await getMemberWorkSyncFeature(services).report({
@ -843,7 +908,7 @@ export function registerTeamRoutes(app: FastifyInstance, services: HttpServices)
...(typeof payload.reportToken === 'string' ...(typeof payload.reportToken === 'string'
? { reportToken: payload.reportToken } ? { reportToken: payload.reportToken }
: {}), : {}),
...(taskIds ? { taskIds } : {}), ...(taskIds?.length ? { taskIds } : {}),
...(typeof payload.note === 'string' ? { note: payload.note } : {}), ...(typeof payload.note === 'string' ? { note: payload.note } : {}),
...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}), ...(typeof payload.reportedAt === 'string' ? { reportedAt: payload.reportedAt } : {}),
...(typeof payload.leaseTtlMs === 'number' ? { leaseTtlMs: payload.leaseTtlMs } : {}), ...(typeof payload.leaseTtlMs === 'number' ? { leaseTtlMs: payload.leaseTtlMs } : {}),

View file

@ -834,7 +834,6 @@ function wireFileWatcherEvents(context: ServiceContext): void {
const teamName = row.teamName.trim(); const teamName = row.teamName.trim();
const detail = typeof row.detail === 'string' ? row.detail : ''; const detail = typeof row.detail === 'string' ? row.detail : '';
launchIoGovernor?.noteTeamChange(row as TeamChangeEvent); launchIoGovernor?.noteTeamChange(row as TeamChangeEvent);
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
if (row.type === 'config') { if (row.type === 'config') {
if (detail === 'config.json') { if (detail === 'config.json') {
@ -850,6 +849,8 @@ function wireFileWatcherEvents(context: ServiceContext): void {
TeamTaskReader.invalidateAllTasksCache(); TeamTaskReader.invalidateAllTasksCache();
} }
memberWorkSyncFeature?.noteTeamChange(row as TeamChangeEvent);
if ( if (
teamDataService && teamDataService &&
(row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config') (row.type === 'inbox' || row.type === 'lead-message' || row.type === 'config')

View file

@ -1065,6 +1065,7 @@ export class FileWatcher extends EventEmitter {
type: 'task', type: 'task',
teamName, teamName,
detail: relative, detail: relative,
taskId: relative.replace(/\.json$/i, ''),
}; };
this.emit('team-change', event); this.emit('team-change', event);
} }

View file

@ -305,6 +305,9 @@ interface PersistedRuntimeMemberLike {
backendType?: string; backendType?: string;
providerId?: string; providerId?: string;
cwd?: string; cwd?: string;
bootstrapExpectedAfter?: string;
bootstrapProofToken?: string;
bootstrapRuntimeEventsPath?: string;
runtimePid?: number; runtimePid?: number;
runtimeSessionId?: string; runtimeSessionId?: string;
} }
@ -338,6 +341,40 @@ interface LaunchStateWriteResult {
type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text'; type BootstrapTranscriptSuccessSource = 'member_briefing' | 'assistant_text';
const BOOTSTRAP_RUNTIME_PROOF_SOURCE = 'member_briefing_tool_success';
const BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES = 256 * 1024;
function sanitizeRuntimeEventFilePrefix(value: string): string {
return String(value || 'default')
.replace(/[^a-zA-Z0-9]/g, '-')
.toLowerCase();
}
function parseRuntimeBootstrapProofDetail(detail: unknown): Record<string, unknown> {
if (typeof detail !== 'string' || detail.trim().length === 0) {
return {};
}
try {
const parsed = JSON.parse(detail) as unknown;
return parsed && typeof parsed === 'object' ? (parsed as Record<string, unknown>) : {};
} catch {
return {};
}
}
function getRuntimeBootstrapProofString(
event: Record<string, unknown>,
detail: Record<string, unknown>,
field: 'source' | 'bootstrapProofToken'
): string | undefined {
const direct = event[field];
if (typeof direct === 'string' && direct.trim().length > 0) {
return direct.trim();
}
const nested = detail[field];
return typeof nested === 'string' && nested.trim().length > 0 ? nested.trim() : undefined;
}
type BootstrapTranscriptOutcome = type BootstrapTranscriptOutcome =
| { | {
kind: 'success'; kind: 'success';
@ -2658,6 +2695,14 @@ function isBootstrapMcpResourceReadFailureReason(reason?: string): boolean {
); );
} }
function isBootstrapCheckInTimeoutFailureReason(reason?: string): boolean {
return reason?.trim() === 'Teammate was registered but did not bootstrap-confirm before timeout.';
}
function isBootstrapInstructionPromptFailureReason(reason?: string): boolean {
return typeof reason === 'string' && isBootstrapInstructionPrompt(reason);
}
function isTmuxNoServerRunningError(error: unknown): boolean { function isTmuxNoServerRunningError(error: unknown): boolean {
const text = error instanceof Error ? error.message : String(error ?? ''); const text = error instanceof Error ? error.message : String(error ?? '');
return ( return (
@ -2673,7 +2718,9 @@ function isAutoClearableLaunchFailureReason(reason?: string): boolean {
isConfigRegistrationFailureReason(reason) || isConfigRegistrationFailureReason(reason) ||
isRegisteredRuntimeMetadataFailureReason(reason) || isRegisteredRuntimeMetadataFailureReason(reason) ||
isOpenCodeBridgeLaunchFailureReason(reason) || isOpenCodeBridgeLaunchFailureReason(reason) ||
isBootstrapMcpResourceReadFailureReason(reason) isBootstrapMcpResourceReadFailureReason(reason) ||
isBootstrapCheckInTimeoutFailureReason(reason) ||
isBootstrapInstructionPromptFailureReason(reason)
); );
} }
@ -6511,6 +6558,12 @@ export class TeamProvisioningService {
const toolNames = ledgerRecord?.observedToolCallNames ?? []; const toolNames = ledgerRecord?.observedToolCallNames ?? [];
return toolNames.some((toolName) => { return toolNames.some((toolName) => {
const normalized = this.normalizeOpenCodeObservedToolName(toolName); const normalized = this.normalizeOpenCodeObservedToolName(toolName);
if (
ledgerRecord?.messageKind === 'member_work_sync_nudge' &&
normalized === 'member_work_sync_report'
) {
return true;
}
return ( return (
normalized === 'task_start' || normalized === 'task_start' ||
normalized === 'task_add_comment' || normalized === 'task_add_comment' ||
@ -6530,6 +6583,7 @@ export class TeamProvisioningService {
private normalizeOpenCodeObservedToolName(toolName: string): string { private normalizeOpenCodeObservedToolName(toolName: string): string {
return toolName return toolName
.trim() .trim()
.toLowerCase()
.replace(/^mcp__agent[-_]teams__/, '') .replace(/^mcp__agent[-_]teams__/, '')
.replace(/^agent[-_]teams_/, '') .replace(/^agent[-_]teams_/, '')
.replace(/^mcp__agent_teams__/, '') .replace(/^mcp__agent_teams__/, '')
@ -6605,9 +6659,34 @@ export class TeamProvisioningService {
if (state === 'empty_assistant_turn') { if (state === 'empty_assistant_turn') {
return 'empty_assistant_turn'; return 'empty_assistant_turn';
} }
if (state === 'prompt_delivered_no_assistant_message') {
return 'prompt_delivered_no_assistant_message';
}
return record?.lastReason ?? 'opencode_delivery_response_pending'; return record?.lastReason ?? 'opencode_delivery_response_pending';
} }
private normalizeOpenCodeDeliveryResponseObservation(
observation?: NonNullable<OpenCodeTeamRuntimeMessageResult['responseObservation']>
): NonNullable<OpenCodeTeamRuntimeMessageResult['responseObservation']> | undefined {
if (
observation?.state !== 'empty_assistant_turn' ||
!observation.deliveredUserMessageId ||
observation.assistantMessageId ||
observation.latestAssistantPreview?.trim() ||
observation.toolCallNames.length > 0 ||
observation.visibleMessageToolCallId ||
observation.visibleReplyMessageId
) {
return observation;
}
return {
...observation,
state: 'prompt_delivered_no_assistant_message',
reason: 'prompt_delivered_no_assistant_message',
};
}
private isOpenCodeDeliveryRetryablePendingResponse(input: { private isOpenCodeDeliveryRetryablePendingResponse(input: {
ledgerRecord: OpenCodePromptDeliveryLedgerRecord; ledgerRecord: OpenCodePromptDeliveryLedgerRecord;
visibleReply?: OpenCodeVisibleReplyProof | null; visibleReply?: OpenCodeVisibleReplyProof | null;
@ -7377,6 +7456,7 @@ export class TeamProvisioningService {
source: 'watchdog', source: 'watchdog',
replyRecipient, replyRecipient,
actionMode: message.actionMode ?? null, actionMode: message.actionMode ?? null,
messageKind: message.messageKind ?? null,
taskRefs: message.taskRefs ?? [], taskRefs: message.taskRefs ?? [],
payloadHash: hashOpenCodePromptDeliveryPayload({ payloadHash: hashOpenCodePromptDeliveryPayload({
text: message.text, text: message.text,
@ -7428,6 +7508,7 @@ export class TeamProvisioningService {
messageId?: string; messageId?: string;
replyRecipient?: string; replyRecipient?: string;
actionMode?: AgentActionMode; actionMode?: AgentActionMode;
messageKind?: InboxMessage['messageKind'];
taskRefs?: TaskRef[]; taskRefs?: TaskRef[];
source?: OpenCodeMemberInboxRelayOptions['source']; source?: OpenCodeMemberInboxRelayOptions['source'];
inboxTimestamp?: string; inboxTimestamp?: string;
@ -7494,7 +7575,7 @@ export class TeamProvisioningService {
lane.member.name.trim().toLowerCase() === normalizedMemberName.toLowerCase() lane.member.name.trim().toLowerCase() === normalizedMemberName.toLowerCase()
); );
trackedSecondaryLanePresent = liveLane != null; trackedSecondaryLanePresent = liveLane != null;
liveSecondaryLaneRunId = liveLane ? trackedRunId : null; liveSecondaryLaneRunId = liveLane?.runId?.trim() || null;
const liveLaneMember = liveLane const liveLaneMember = liveLane
? (liveLane.result?.members?.[canonicalMemberName] ?? ? (liveLane.result?.members?.[canonicalMemberName] ??
liveLane.result?.members?.[liveLane.member.name]) liveLane.result?.members?.[liveLane.member.name])
@ -7628,6 +7709,7 @@ export class TeamProvisioningService {
messageId: input.messageId, messageId: input.messageId,
replyRecipient: input.replyRecipient, replyRecipient: input.replyRecipient,
actionMode: input.actionMode, actionMode: input.actionMode,
messageKind: input.messageKind,
taskRefs: input.taskRefs, taskRefs: input.taskRefs,
}); });
await this.rememberOpenCodeRuntimePidFromBridge({ await this.rememberOpenCodeRuntimePidFromBridge({
@ -7639,11 +7721,14 @@ export class TeamProvisioningService {
runtimePid: result.runtimePid, runtimePid: result.runtimePid,
reason: 'opencode_delivery_runtime_pid_observed', reason: 'opencode_delivery_runtime_pid_observed',
}); });
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
result.responseObservation
);
return { return {
delivered: result.ok, delivered: result.ok,
accepted: result.ok, accepted: result.ok,
responsePending: false, responsePending: false,
responseState: result.responseObservation?.state, responseState: responseObservation?.state,
...(result.ok ...(result.ok
? {} ? {}
: { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }), : { reason: result.diagnostics[0] ?? 'opencode_message_delivery_failed' }),
@ -7731,6 +7816,7 @@ export class TeamProvisioningService {
source: input.source ?? 'manual', source: input.source ?? 'manual',
replyRecipient: input.replyRecipient ?? 'user', replyRecipient: input.replyRecipient ?? 'user',
actionMode: input.actionMode ?? null, actionMode: input.actionMode ?? null,
messageKind: input.messageKind ?? null,
taskRefs: input.taskRefs ?? [], taskRefs: input.taskRefs ?? [],
payloadHash: hashOpenCodePromptDeliveryPayload({ payloadHash: hashOpenCodePromptDeliveryPayload({
text: input.text, text: input.text,
@ -7868,6 +7954,7 @@ export class TeamProvisioningService {
messageId, messageId,
replyRecipient: input.replyRecipient, replyRecipient: input.replyRecipient,
actionMode: input.actionMode, actionMode: input.actionMode,
messageKind: input.messageKind,
taskRefs: input.taskRefs, taskRefs: input.taskRefs,
prePromptCursor: ledgerRecord.prePromptCursor, prePromptCursor: ledgerRecord.prePromptCursor,
}); });
@ -7880,9 +7967,12 @@ export class TeamProvisioningService {
runtimePid: observed.runtimePid, runtimePid: observed.runtimePid,
reason: 'opencode_delivery_observe_runtime_pid_observed', reason: 'opencode_delivery_observe_runtime_pid_observed',
}); });
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
observed.responseObservation
);
ledgerRecord = await ledger.applyObservation({ ledgerRecord = await ledger.applyObservation({
id: ledgerRecord.id, id: ledgerRecord.id,
responseObservation: observed.responseObservation ?? { responseObservation: responseObservation ?? {
state: observed.ok ? 'not_observed' : 'reconcile_failed', state: observed.ok ? 'not_observed' : 'reconcile_failed',
deliveredUserMessageId: null, deliveredUserMessageId: null,
assistantMessageId: null, assistantMessageId: null,
@ -7993,6 +8083,7 @@ export class TeamProvisioningService {
messageId: input.messageId, messageId: input.messageId,
replyRecipient: input.replyRecipient, replyRecipient: input.replyRecipient,
actionMode: input.actionMode, actionMode: input.actionMode,
messageKind: input.messageKind,
taskRefs: input.taskRefs, taskRefs: input.taskRefs,
}); });
await this.rememberOpenCodeRuntimePidFromBridge({ await this.rememberOpenCodeRuntimePidFromBridge({
@ -8004,16 +8095,19 @@ export class TeamProvisioningService {
runtimePid: result.runtimePid, runtimePid: result.runtimePid,
reason: 'opencode_delivery_runtime_pid_observed', reason: 'opencode_delivery_runtime_pid_observed',
}); });
const responseObservation = this.normalizeOpenCodeDeliveryResponseObservation(
result.responseObservation
);
if (ledgerRecord && ledger) { if (ledgerRecord && ledger) {
ledgerRecord = await ledger.applyDeliveryResult({ ledgerRecord = await ledger.applyDeliveryResult({
id: ledgerRecord.id, id: ledgerRecord.id,
accepted: result.ok, accepted: result.ok,
attempted: true, attempted: true,
responseObservation: result.responseObservation, responseObservation,
sessionId: result.sessionId, sessionId: result.sessionId,
prePromptCursor: result.prePromptCursor, prePromptCursor: result.prePromptCursor,
diagnostics: result.diagnostics, diagnostics: result.diagnostics,
reason: result.ok ? result.responseObservation?.reason : result.diagnostics[0], reason: result.ok ? responseObservation?.reason : result.diagnostics[0],
now: nowIso(), now: nowIso(),
}); });
let proof = await this.applyOpenCodeVisibleDestinationProof({ let proof = await this.applyOpenCodeVisibleDestinationProof({
@ -8044,7 +8138,7 @@ export class TeamProvisioningService {
{ accepted: result.ok, reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null } { accepted: result.ok, reason: ledgerRecord.lastReason ?? result.diagnostics[0] ?? null }
); );
} }
const responseState = ledgerRecord?.responseState ?? result.responseObservation?.state; const responseState = ledgerRecord?.responseState ?? responseObservation?.state;
const visibleReply = ledgerRecord const visibleReply = ledgerRecord
? await this.findOpenCodeVisibleReplyByRelayOfMessageId({ ? await this.findOpenCodeVisibleReplyByRelayOfMessageId({
teamName, teamName,
@ -8139,15 +8233,15 @@ export class TeamProvisioningService {
} }
const responseVisibleReplyMessageId = const responseVisibleReplyMessageId =
ledgerRecord?.visibleReplyMessageId ?? ledgerRecord?.visibleReplyMessageId ??
result.responseObservation?.visibleReplyMessageId ?? responseObservation?.visibleReplyMessageId ??
undefined; undefined;
const responseVisibleReplyCorrelation = const responseVisibleReplyCorrelation =
ledgerRecord?.visibleReplyCorrelation ?? ledgerRecord?.visibleReplyCorrelation ??
result.responseObservation?.visibleReplyCorrelation ?? responseObservation?.visibleReplyCorrelation ??
undefined; undefined;
const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !result.ok); const acceptanceUnknown = Boolean(ledgerRecord?.acceptanceUnknown && !result.ok);
const responsePending = const responsePending =
acceptanceUnknown || (result.ok && Boolean(ledgerRecord || result.responseObservation)) acceptanceUnknown || (result.ok && Boolean(ledgerRecord || responseObservation))
? !readAllowed ? !readAllowed
: false; : false;
const pendingReason = const pendingReason =
@ -8162,8 +8256,8 @@ export class TeamProvisioningService {
: result.diagnostics; : result.diagnostics;
return { return {
delivered: result.ok || acceptanceUnknown, delivered: result.ok || acceptanceUnknown,
...(ledgerRecord || result.responseObservation ? { accepted: result.ok } : {}), ...(ledgerRecord || responseObservation ? { accepted: result.ok } : {}),
...(ledgerRecord || result.responseObservation ? { responsePending } : {}), ...(ledgerRecord || responseObservation ? { responsePending } : {}),
...(acceptanceUnknown ? { acceptanceUnknown: true } : {}), ...(acceptanceUnknown ? { acceptanceUnknown: true } : {}),
...(ledgerRecord ...(ledgerRecord
? { ? {
@ -11521,7 +11615,8 @@ export class TeamProvisioningService {
private confirmMemberSpawnStatusFromTranscript( private confirmMemberSpawnStatusFromTranscript(
run: ProvisioningRun, run: ProvisioningRun,
memberName: string, memberName: string,
observedAt: string observedAt: string,
source: 'transcript' | 'runtime-proof' = 'transcript'
): void { ): void {
const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry(); const prev = run.memberSpawnStatuses.get(memberName) ?? createInitialMemberSpawnStatusEntry();
const updatedAt = nowIso(); const updatedAt = nowIso();
@ -11530,7 +11625,7 @@ export class TeamProvisioningService {
status: 'online', status: 'online',
updatedAt, updatedAt,
agentToolAccepted: true, agentToolAccepted: true,
runtimeAlive: prev.runtimeAlive === true, runtimeAlive: source === 'runtime-proof' ? true : prev.runtimeAlive === true,
bootstrapConfirmed: true, bootstrapConfirmed: true,
hardFailure: false, hardFailure: false,
bootstrapStalled: undefined, bootstrapStalled: undefined,
@ -11564,7 +11659,13 @@ export class TeamProvisioningService {
run.memberSpawnStatuses.set(memberName, next); run.memberSpawnStatuses.set(memberName, next);
run.pendingMemberRestarts?.delete(memberName); run.pendingMemberRestarts?.delete(memberName);
this.syncMemberLaunchGraceCheck(run, memberName, next); this.syncMemberLaunchGraceCheck(run, memberName, next);
this.appendMemberBootstrapDiagnostic(run, memberName, 'bootstrap confirmed via transcript'); this.appendMemberBootstrapDiagnostic(
run,
memberName,
source === 'runtime-proof'
? 'bootstrap confirmed via runtime proof'
: 'bootstrap confirmed via transcript'
);
if (!this.isCurrentTrackedRun(run)) return; if (!this.isCurrentTrackedRun(run)) return;
this.emitMemberSpawnChange(run, memberName); this.emitMemberSpawnChange(run, memberName);
if (run.isLaunch) { if (run.isLaunch) {
@ -13914,12 +14015,26 @@ export class TeamProvisioningService {
(current.launchState === 'failed_to_start' && !canClearFailedBootstrap) || (current.launchState === 'failed_to_start' && !canClearFailedBootstrap) ||
current.launchState === 'confirmed_alive' || current.launchState === 'confirmed_alive' ||
current.bootstrapConfirmed === true || current.bootstrapConfirmed === true ||
current.agentToolAccepted !== true (current.agentToolAccepted !== true && !canClearFailedBootstrap)
) { ) {
continue; continue;
} }
const acceptedAtMs = const acceptedAtMs =
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt(
run.teamName,
memberName,
current
);
if (runtimeProofObservedAt) {
this.confirmMemberSpawnStatusFromTranscript(
run,
memberName,
runtimeProofObservedAt,
'runtime-proof'
);
continue;
}
const transcriptOutcome = await this.findBootstrapTranscriptOutcome( const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
run.teamName, run.teamName,
memberName, memberName,
@ -18382,6 +18497,7 @@ export class TeamProvisioningService {
source: effectiveSource, source: effectiveSource,
replyRecipient: effectiveReplyRecipient, replyRecipient: effectiveReplyRecipient,
actionMode: effectiveActionMode, actionMode: effectiveActionMode,
messageKind: message.messageKind ?? null,
taskRefs: effectiveTaskRefs, taskRefs: effectiveTaskRefs,
payloadHash: hashOpenCodePromptDeliveryPayload({ payloadHash: hashOpenCodePromptDeliveryPayload({
text: message.text, text: message.text,
@ -18422,6 +18538,7 @@ export class TeamProvisioningService {
messageId: message.messageId, messageId: message.messageId,
replyRecipient: effectiveReplyRecipient, replyRecipient: effectiveReplyRecipient,
actionMode: effectiveActionMode ?? undefined, actionMode: effectiveActionMode ?? undefined,
messageKind: message.messageKind,
taskRefs: effectiveTaskRefs, taskRefs: effectiveTaskRefs,
source: effectiveSource, source: effectiveSource,
inboxTimestamp: message.timestamp, inboxTimestamp: message.timestamp,
@ -18811,6 +18928,10 @@ export class TeamProvisioningService {
...(member.role?.trim() ? { role: member.role.trim() } : {}), ...(member.role?.trim() ? { role: member.role.trim() } : {}),
})); }));
const rosterContextBlock = buildLeadRosterContextBlock(teamName, leadName, teammateRoster); const rosterContextBlock = buildLeadRosterContextBlock(teamName, leadName, teammateRoster);
const workSyncControlUrl = await this.resolveControlApiBaseUrl();
const workSyncControlUrlClause = workSyncControlUrl
? `, controlUrl="${workSyncControlUrl}"`
: '';
run.activeCrossTeamReplyHints = batch.flatMap((m) => { run.activeCrossTeamReplyHints = batch.flatMap((m) => {
if (m.source !== 'cross_team') return []; if (m.source !== 'cross_team') return [];
const sourceTeam = m.from.includes('.') ? m.from.split('.', 1)[0] : ''; const sourceTeam = m.from.includes('.') ? m.from.split('.', 1)[0] : '';
@ -18835,6 +18956,7 @@ export class TeamProvisioningService {
`For any MCP board tool call in this turn, teamName MUST be "${teamName}". Never use the lead/member name "${leadName}" as teamName.`, `For any MCP board tool call in this turn, teamName MUST be "${teamName}". Never use the lead/member name "${leadName}" as teamName.`,
`Use task_create_from_message only for messages below that explicitly say "Eligible for task_create_from_message: yes" and provide a User MessageId. Never use task_create_from_message for teammate messages, system notifications, cross-team messages, or any inbox row that is not explicitly marked eligible.`, `Use task_create_from_message only for messages below that explicitly say "Eligible for task_create_from_message: yes" and provide a User MessageId. Never use task_create_from_message for teammate messages, system notifications, cross-team messages, or any inbox row that is not explicitly marked eligible.`,
`If a message below is marked Source: system_notification and its summary looks like "Comment on #...", reply via task_add_comment only when you have a substantive board update (decision, blocker, clarification answer, review result, or concrete next-step change).`, `If a message below is marked Source: system_notification and its summary looks like "Comment on #...", reply via task_add_comment only when you have a substantive board update (decision, blocker, clarification answer, review result, or concrete next-step change).`,
`If a message below has Message kind: member_work_sync_nudge, it is actionable work-sync control traffic, not routine notification noise. Do NOT ignore it as a pure system notification. Call member_work_sync_status with teamName="${teamName}", memberName="${leadName}"${workSyncControlUrlClause}, then call member_work_sync_report with the same teamName/memberName${workSyncControlUrlClause}, the returned agendaFingerprint/reportToken, and taskIds from the nudge task refs. Do not use provider names, runtime names, or team names as memberName. If the agenda still has actionable work you are continuing, use state "still_working"; if blocked, use state "blocked" and record the blocker on the task.`,
`Do NOT post acknowledgement-only task comments such as "Принято", "Ок", "На связи", "Жду", or similar low-signal echoes. If the task comment notification is FYI and no durable update is needed, say nothing.`, `Do NOT post acknowledgement-only task comments such as "Принято", "Ок", "На связи", "Жду", or similar low-signal echoes. If the task comment notification is FYI and no durable update is needed, say nothing.`,
`If a message below includes a hidden structured task-context block, treat that block as authoritative for teamName/taskId/commentId. Do NOT infer alternate ids or namespaces from visible prose.`, `If a message below includes a hidden structured task-context block, treat that block as authoritative for teamName/taskId/commentId. Do NOT infer alternate ids or namespaces from visible prose.`,
`If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`, `If a message below is marked Source: cross_team, CALL the MCP tool named cross_team_send. Do NOT use SendMessage or message_send for cross-team replies.`,
@ -22658,6 +22780,19 @@ export class TeamProvisioningService {
} }
const acceptedAtMs = const acceptedAtMs =
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
if (
current.launchState !== 'failed_to_start' ||
isAutoClearableLaunchFailureReason(current.hardFailureReason ?? current.runtimeDiagnostic)
) {
const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt(
snapshot.teamName,
expected,
current
);
if (runtimeProofObservedAt) {
return true;
}
}
const transcriptOutcome = await this.findBootstrapTranscriptOutcome( const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
snapshot.teamName, snapshot.teamName,
expected, expected,
@ -22673,6 +22808,159 @@ export class TeamProvisioningService {
return false; return false;
} }
private resolveBootstrapRuntimeMember(
teamName: string,
memberName: string
): PersistedRuntimeMemberLike | undefined {
return this.readPersistedRuntimeMembers(teamName).find((member) => {
const candidateName = typeof member.name === 'string' ? member.name.trim() : '';
return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName);
});
}
private getBootstrapRuntimeEventsPath(
teamName: string,
memberName: string,
runtimeMember: PersistedRuntimeMemberLike | undefined
): string {
const configuredPath = runtimeMember?.bootstrapRuntimeEventsPath?.trim();
if (configuredPath) {
return configuredPath;
}
const filePrefix = sanitizeRuntimeEventFilePrefix(runtimeMember?.name ?? memberName);
return path.join(getTeamsBasePath(), teamName, 'runtime', `${filePrefix}.runtime.jsonl`);
}
private async readRuntimeBootstrapProofEvents(
eventsPath: string
): Promise<Record<string, unknown>[]> {
let handle: fs.promises.FileHandle | null = null;
try {
handle = await fs.promises.open(eventsPath, 'r');
const stat = await handle.stat();
if (!stat.isFile() || stat.size <= 0) {
return [];
}
const start = Math.max(0, stat.size - BOOTSTRAP_RUNTIME_PROOF_TAIL_BYTES);
const buffer = Buffer.alloc(stat.size - start);
if (buffer.length === 0) {
return [];
}
await handle.read(buffer, 0, buffer.length, start);
const lines = buffer.toString('utf8').split('\n');
if (start > 0) {
lines.shift();
}
const events: Record<string, unknown>[] = [];
for (const rawLine of lines) {
const line = rawLine.trim();
if (!line) continue;
try {
const parsed = JSON.parse(line) as unknown;
if (
parsed &&
typeof parsed === 'object' &&
(parsed as { version?: unknown }).version === 1 &&
typeof (parsed as { type?: unknown }).type === 'string' &&
typeof (parsed as { timestamp?: unknown }).timestamp === 'string'
) {
events.push(parsed as Record<string, unknown>);
}
} catch {
// Ignore partial lines from concurrently written runtime event files.
}
}
return events;
} catch {
return [];
} finally {
await handle?.close().catch(() => undefined);
}
}
private isRuntimeBootstrapProofEventValid(input: {
event: Record<string, unknown>;
detail: Record<string, unknown>;
teamName: string;
memberName: string;
runtimeMember?: PersistedRuntimeMemberLike;
boundaryMs: number;
}): boolean {
const { event, detail, teamName, memberName, runtimeMember, boundaryMs } = input;
if (event.type !== 'bootstrap_confirmed') {
return false;
}
if (typeof event.teamName === 'string' && event.teamName.trim() !== teamName) {
return false;
}
const source = getRuntimeBootstrapProofString(event, detail, 'source');
if (source !== BOOTSTRAP_RUNTIME_PROOF_SOURCE) {
return false;
}
const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
const eventMs = Date.parse(timestamp);
if (Number.isFinite(boundaryMs) && (!Number.isFinite(eventMs) || eventMs < boundaryMs)) {
return false;
}
const expectedToken = runtimeMember?.bootstrapProofToken?.trim();
const eventToken = getRuntimeBootstrapProofString(event, detail, 'bootstrapProofToken');
if (expectedToken && eventToken !== expectedToken) {
return false;
}
const eventAgentName = typeof event.agentName === 'string' ? event.agentName.trim() : '';
const eventAgentId = typeof event.agentId === 'string' ? event.agentId.trim() : '';
const runtimeName = runtimeMember?.name?.trim() ?? '';
const runtimeAgentId = runtimeMember?.agentId?.trim() ?? '';
return (
(eventAgentName.length > 0 &&
(matchesMemberNameOrBase(eventAgentName, memberName) ||
(runtimeName.length > 0 && matchesTeamMemberIdentity(eventAgentName, runtimeName)))) ||
(eventAgentId.length > 0 && runtimeAgentId.length > 0 && eventAgentId === runtimeAgentId)
);
}
private async findBootstrapRuntimeProofObservedAt(
teamName: string,
memberName: string,
member: Pick<
PersistedTeamLaunchMemberState,
'firstSpawnAcceptedAt' | 'launchState' | 'hardFailureReason'
>
): Promise<string | null> {
const runtimeMember = this.resolveBootstrapRuntimeMember(teamName, memberName);
const boundaryText = member.firstSpawnAcceptedAt ?? runtimeMember?.bootstrapExpectedAfter;
const boundaryMs = boundaryText ? Date.parse(boundaryText) : Number.NaN;
if (!runtimeMember?.bootstrapProofToken && !Number.isFinite(boundaryMs)) {
return null;
}
const eventsPath = this.getBootstrapRuntimeEventsPath(teamName, memberName, runtimeMember);
const events = await this.readRuntimeBootstrapProofEvents(eventsPath);
let latest: string | null = null;
let latestMs = Number.NEGATIVE_INFINITY;
for (const event of events) {
const detail = parseRuntimeBootstrapProofDetail(event.detail);
if (
!this.isRuntimeBootstrapProofEventValid({
event,
detail,
teamName,
memberName,
runtimeMember,
boundaryMs,
})
) {
continue;
}
const timestamp = typeof event.timestamp === 'string' ? event.timestamp : '';
const timestampMs = Date.parse(timestamp);
if (Number.isFinite(timestampMs) && timestampMs >= latestMs) {
latest = timestamp;
latestMs = timestampMs;
}
}
return latest;
}
private async applyBootstrapTranscriptEvidenceOverlay( private async applyBootstrapTranscriptEvidenceOverlay(
snapshot: PersistedTeamLaunchSnapshot | null snapshot: PersistedTeamLaunchSnapshot | null
): Promise<PersistedTeamLaunchSnapshot | null> { ): Promise<PersistedTeamLaunchSnapshot | null> {
@ -22691,15 +22979,30 @@ export class TeamProvisioningService {
) { ) {
continue; continue;
} }
const failureReason = current.hardFailureReason ?? current.runtimeDiagnostic;
const canClearFailedBootstrap =
current.launchState !== 'failed_to_start' ||
isAutoClearableLaunchFailureReason(failureReason);
if (!canClearFailedBootstrap) {
continue;
}
const acceptedAtMs = const acceptedAtMs =
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
const runtimeProofObservedAt = await this.findBootstrapRuntimeProofObservedAt(
snapshot.teamName,
expected,
current
);
const transcriptOutcome = await this.findBootstrapTranscriptOutcome( const transcriptOutcome = await this.findBootstrapTranscriptOutcome(
snapshot.teamName, snapshot.teamName,
expected, expected,
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
); );
if (transcriptOutcome?.kind !== 'success') { const observedAt =
runtimeProofObservedAt ??
(transcriptOutcome?.kind === 'success' ? transcriptOutcome.observedAt : null);
if (!observedAt) {
continue; continue;
} }
@ -22707,9 +23010,13 @@ export class TeamProvisioningService {
...current, ...current,
agentToolAccepted: true, agentToolAccepted: true,
bootstrapConfirmed: true, bootstrapConfirmed: true,
runtimeAlive: runtimeProofObservedAt ? true : current.runtimeAlive === true,
hardFailure: false, hardFailure: false,
hardFailureReason: undefined, hardFailureReason: undefined,
lastHeartbeatAt: current.lastHeartbeatAt ?? transcriptOutcome.observedAt, lastHeartbeatAt: current.lastHeartbeatAt ?? observedAt,
lastRuntimeAliveAt: runtimeProofObservedAt
? (current.lastRuntimeAliveAt ?? observedAt)
: current.lastRuntimeAliveAt,
lastEvaluatedAt: nowIso(), lastEvaluatedAt: nowIso(),
sources: { sources: {
...(current.sources ?? {}), ...(current.sources ?? {}),
@ -22997,6 +23304,8 @@ export class TeamProvisioningService {
: null; : null;
const acceptedAtMs = const acceptedAtMs =
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN; current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
const initialFailureReason = current.hardFailureReason ?? current.runtimeDiagnostic;
const hadAutoClearableFailure = isAutoClearableLaunchFailureReason(initialFailureReason);
current.runtimeAlive = observedRuntimeAlive; current.runtimeAlive = observedRuntimeAlive;
current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt; current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt;
current.livenessKind = runtimeMetadata?.[1].livenessKind; current.livenessKind = runtimeMetadata?.[1].livenessKind;
@ -23019,7 +23328,7 @@ export class TeamProvisioningService {
const currentProvesSpawnAcceptance = const currentProvesSpawnAcceptance =
current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string'; current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string';
if ( if (
isAutoClearableLaunchFailureReason(current.hardFailureReason) && hadAutoClearableFailure &&
(bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance) (bootstrapProvesSpawnAcceptance || currentProvesSpawnAcceptance)
) { ) {
current.hardFailure = false; current.hardFailure = false;
@ -23049,15 +23358,34 @@ export class TeamProvisioningService {
current.hardFailure = false; current.hardFailure = false;
current.hardFailureReason = undefined; current.hardFailureReason = undefined;
} }
if (!current.bootstrapConfirmed) { const canApplyBootstrapSuccess =
const transcriptOutcome = await this.findBootstrapTranscriptOutcome( !heartbeatReason &&
teamName, (current.launchState !== 'failed_to_start' ||
expected, hadAutoClearableFailure ||
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null isAutoClearableLaunchFailureReason(
); current.hardFailureReason ?? current.runtimeDiagnostic
if (transcriptOutcome?.kind === 'success' && !isOpenCodeSecondaryLaneMember) { ));
if (!current.bootstrapConfirmed && canApplyBootstrapSuccess) {
const runtimeProofObservedAt = !isOpenCodeSecondaryLaneMember
? await this.findBootstrapRuntimeProofObservedAt(teamName, expected, current)
: null;
const transcriptOutcome = runtimeProofObservedAt
? null
: await this.findBootstrapTranscriptOutcome(
teamName,
expected,
Number.isFinite(acceptedAtMs) ? acceptedAtMs : null
);
const bootstrapObservedAt =
runtimeProofObservedAt ??
(transcriptOutcome?.kind === 'success' ? transcriptOutcome.observedAt : null);
if (bootstrapObservedAt && !isOpenCodeSecondaryLaneMember) {
current.bootstrapConfirmed = true; current.bootstrapConfirmed = true;
current.lastHeartbeatAt = current.lastHeartbeatAt ?? transcriptOutcome.observedAt; current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapObservedAt;
current.runtimeAlive = runtimeProofObservedAt ? true : current.runtimeAlive === true;
current.lastRuntimeAliveAt = runtimeProofObservedAt
? (current.lastRuntimeAliveAt ?? bootstrapObservedAt)
: current.lastRuntimeAliveAt;
current.hardFailure = false; current.hardFailure = false;
current.hardFailureReason = undefined; current.hardFailureReason = undefined;
if (current.sources) { if (current.sources) {

View file

@ -161,6 +161,13 @@ export interface OpenCodeSendMessageCommandBody {
text: string; text: string;
messageId?: string; messageId?: string;
actionMode?: 'do' | 'ask' | 'delegate'; actionMode?: 'do' | 'ask' | 'delegate';
messageKind?:
| 'default'
| 'slash_command'
| 'slash_command_result'
| 'task_comment_notification'
| 'member_work_sync_nudge'
| 'agent_error';
taskRefs?: { taskId: string; displayId: string; teamName: string }[]; taskRefs?: { taskId: string; displayId: string; teamName: string }[];
agent?: string; agent?: string;
noReply?: boolean; noReply?: boolean;
@ -177,6 +184,7 @@ export type OpenCodeDeliveryResponseState =
| 'permission_blocked' | 'permission_blocked'
| 'tool_error' | 'tool_error'
| 'empty_assistant_turn' | 'empty_assistant_turn'
| 'prompt_delivered_no_assistant_message'
| 'session_stale' | 'session_stale'
| 'session_error' | 'session_error'
| 'reconcile_failed'; | 'reconcile_failed';

View file

@ -6,7 +6,7 @@ import type {
OpenCodeDeliveryResponseState, OpenCodeDeliveryResponseState,
OpenCodeDeliveryVisibleReplyCorrelation, OpenCodeDeliveryVisibleReplyCorrelation,
} from '../bridge/OpenCodeBridgeCommandContract'; } from '../bridge/OpenCodeBridgeCommandContract';
import type { AgentActionMode, TaskRef } from '@shared/types/team'; import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
export const OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION = 1; export const OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION = 1;
export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000;
@ -32,6 +32,7 @@ export interface OpenCodePromptDeliveryLedgerRecord {
inboxMessageId: string; inboxMessageId: string;
inboxTimestamp: string; inboxTimestamp: string;
source: 'watcher' | 'ui-send' | 'manual' | 'watchdog'; source: 'watcher' | 'ui-send' | 'manual' | 'watchdog';
messageKind: InboxMessageKind | null;
replyRecipient: string; replyRecipient: string;
actionMode: AgentActionMode | null; actionMode: AgentActionMode | null;
taskRefs: TaskRef[]; taskRefs: TaskRef[];
@ -87,6 +88,7 @@ const OPENCODE_DELIVERY_RESPONSE_STATES = new Set<OpenCodeDeliveryResponseState>
'permission_blocked', 'permission_blocked',
'tool_error', 'tool_error',
'empty_assistant_turn', 'empty_assistant_turn',
'prompt_delivered_no_assistant_message',
'session_stale', 'session_stale',
'session_error', 'session_error',
'reconcile_failed', 'reconcile_failed',
@ -116,6 +118,7 @@ export interface EnsureOpenCodePromptDeliveryInput {
inboxMessageId: string; inboxMessageId: string;
inboxTimestamp: string; inboxTimestamp: string;
source: OpenCodePromptDeliveryLedgerRecord['source']; source: OpenCodePromptDeliveryLedgerRecord['source'];
messageKind?: InboxMessageKind | null;
replyRecipient: string; replyRecipient: string;
actionMode?: AgentActionMode | null; actionMode?: AgentActionMode | null;
taskRefs?: TaskRef[]; taskRefs?: TaskRef[];
@ -174,6 +177,15 @@ export class OpenCodePromptDeliveryLedgerStore {
result = updated; result = updated;
return records.map((record) => (record.id === existing.id ? updated : record)); return records.map((record) => (record.id === existing.id ? updated : record));
} }
if (existing.messageKind == null && input.messageKind) {
const updated: OpenCodePromptDeliveryLedgerRecord = {
...existing,
messageKind: input.messageKind,
updatedAt: input.now,
};
result = updated;
return records.map((record) => (record.id === existing.id ? updated : record));
}
result = existing; result = existing;
return records; return records;
} }
@ -188,6 +200,7 @@ export class OpenCodePromptDeliveryLedgerStore {
inboxMessageId: input.inboxMessageId, inboxMessageId: input.inboxMessageId,
inboxTimestamp: input.inboxTimestamp, inboxTimestamp: input.inboxTimestamp,
source: input.source, source: input.source,
messageKind: input.messageKind ?? null,
replyRecipient: input.replyRecipient, replyRecipient: input.replyRecipient,
actionMode: input.actionMode ?? null, actionMode: input.actionMode ?? null,
taskRefs: input.taskRefs ?? [], taskRefs: input.taskRefs ?? [],
@ -274,7 +287,7 @@ export class OpenCodePromptDeliveryLedgerStore {
const responseState = const responseState =
observation?.state ?? (input.accepted ? record.responseState : 'not_observed'); observation?.state ?? (input.accepted ? record.responseState : 'not_observed');
const responded = isOpenCodePromptResponseStateResponded(responseState); const responded = isOpenCodePromptResponseStateResponded(responseState);
const unanswered = responseState === 'empty_assistant_turn'; const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(responseState);
return { return {
...record, ...record,
status: input.accepted status: input.accepted
@ -321,7 +334,9 @@ export class OpenCodePromptDeliveryLedgerStore {
}): Promise<OpenCodePromptDeliveryLedgerRecord> { }): Promise<OpenCodePromptDeliveryLedgerRecord> {
return await this.updateExisting(input.id, (record) => { return await this.updateExisting(input.id, (record) => {
const responded = isOpenCodePromptResponseStateResponded(input.responseObservation.state); const responded = isOpenCodePromptResponseStateResponded(input.responseObservation.state);
const unanswered = input.responseObservation.state === 'empty_assistant_turn'; const unanswered = isOpenCodePromptDeliveryUnansweredResponseState(
input.responseObservation.state
);
return { return {
...record, ...record,
status: responded status: responded
@ -637,6 +652,12 @@ export function isOpenCodePromptResponseStateResponded(
); );
} }
function isOpenCodePromptDeliveryUnansweredResponseState(
state: OpenCodeDeliveryResponseState
): boolean {
return state === 'empty_assistant_turn' || state === 'prompt_delivered_no_assistant_message';
}
export function isOpenCodePromptDeliveryAttemptDue( export function isOpenCodePromptDeliveryAttemptDue(
record: OpenCodePromptDeliveryLedgerRecord, record: OpenCodePromptDeliveryLedgerRecord,
nowMs: number = Date.now() nowMs: number = Date.now()
@ -682,6 +703,7 @@ function isOpenCodePromptDeliveryLedgerRecord(
typeof record.inboxMessageId === 'string' && typeof record.inboxMessageId === 'string' &&
typeof record.inboxTimestamp === 'string' && typeof record.inboxTimestamp === 'string' &&
isOpenCodePromptDeliverySource(record.source) && isOpenCodePromptDeliverySource(record.source) &&
isOptionalNullableInboxMessageKind(record.messageKind) &&
typeof record.replyRecipient === 'string' && typeof record.replyRecipient === 'string' &&
isOptionalNullableActionMode(record.actionMode) && isOptionalNullableActionMode(record.actionMode) &&
isTaskRefArray(record.taskRefs) && isTaskRefArray(record.taskRefs) &&
@ -760,6 +782,21 @@ function isOptionalNullableActionMode(value: unknown): value is AgentActionMode
); );
} }
function isOptionalNullableInboxMessageKind(
value: unknown
): value is InboxMessageKind | null | undefined {
return (
value === undefined ||
value === null ||
value === 'default' ||
value === 'slash_command' ||
value === 'slash_command_result' ||
value === 'task_comment_notification' ||
value === 'member_work_sync_nudge' ||
value === 'agent_error'
);
}
function isOptionalNullableString(value: unknown): value is string | null | undefined { function isOptionalNullableString(value: unknown): value is string | null | undefined {
return value === undefined || value === null || typeof value === 'string'; return value === undefined || value === null || typeof value === 'string';
} }

View file

@ -90,6 +90,7 @@ export function isOpenCodePromptDeliveryRetryableResponseState(
): boolean { ): boolean {
return ( return (
state === 'empty_assistant_turn' || state === 'empty_assistant_turn' ||
state === 'prompt_delivered_no_assistant_message' ||
state === 'tool_error' || state === 'tool_error' ||
state === 'reconcile_failed' || state === 'reconcile_failed' ||
state === 'not_observed' || state === 'not_observed' ||

View file

@ -26,7 +26,7 @@ import type {
TeamRuntimeStopInput, TeamRuntimeStopInput,
TeamRuntimeStopResult, TeamRuntimeStopResult,
} from './TeamRuntimeAdapter'; } from './TeamRuntimeAdapter';
import type { AgentActionMode, TaskRef } from '@shared/types/team'; import type { AgentActionMode, InboxMessageKind, TaskRef } from '@shared/types/team';
export interface OpenCodeTeamRuntimeBridgePort { export interface OpenCodeTeamRuntimeBridgePort {
checkOpenCodeTeamLaunchReadiness(input: { checkOpenCodeTeamLaunchReadiness(input: {
@ -58,6 +58,7 @@ export interface OpenCodeTeamRuntimeMessageInput {
messageId?: string; messageId?: string;
replyRecipient?: string; replyRecipient?: string;
actionMode?: AgentActionMode; actionMode?: AgentActionMode;
messageKind?: InboxMessageKind;
taskRefs?: TaskRef[]; taskRefs?: TaskRef[];
bootstrapCheckinRetry?: { bootstrapCheckinRetry?: {
runtimeSessionId: string; runtimeSessionId: string;
@ -313,6 +314,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
text: buildOpenCodeRuntimeMessageText(input), text: buildOpenCodeRuntimeMessageText(input),
messageId: input.messageId, messageId: input.messageId,
actionMode: input.actionMode, actionMode: input.actionMode,
messageKind: input.messageKind,
taskRefs: input.taskRefs, taskRefs: input.taskRefs,
agent: 'teammate', agent: 'teammate',
}); });
@ -773,7 +775,7 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
const replyRecipient = input.replyRecipient?.trim() || 'user'; const replyRecipient = input.replyRecipient?.trim() || 'user';
const deliveryContext = const deliveryContext =
input.messageId && input.taskRefs?.length input.messageId && (input.taskRefs?.length || input.messageKind)
? JSON.stringify({ ? JSON.stringify({
schemaVersion: 1, schemaVersion: 1,
kind: 'opencode-delivery-context', kind: 'opencode-delivery-context',
@ -781,9 +783,38 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
laneId: input.laneId, laneId: input.laneId,
memberName: input.memberName, memberName: input.memberName,
inboundMessageId: input.messageId, inboundMessageId: input.messageId,
...(input.messageKind ? { messageKind: input.messageKind } : {}),
taskRefs: input.taskRefs, taskRefs: input.taskRefs,
}) })
: null; : null;
const isWorkSyncNudge = input.messageKind === 'member_work_sync_nudge';
const taskIds =
input.taskRefs
?.map((ref) => ref.taskId?.trim())
.filter((taskId): taskId is string => Boolean(taskId)) ?? [];
const responseInstructions = isWorkSyncNudge
? [
'This delivered app message is a member-work-sync nudge.',
'A visible agent-teams_message_send reply is optional. Concrete task progress or agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) is sufficient response proof.',
`Call agent-teams_member_work_sync_status (or mcp__agent-teams__member_work_sync_status) with teamName="${input.teamName}" and memberName="${input.memberName}".`,
`Then call agent-teams_member_work_sync_report (or mcp__agent-teams__member_work_sync_report) with teamName="${input.teamName}", memberName="${input.memberName}", the returned agendaFingerprint/reportToken, and state "still_working" or "blocked".`,
taskIds.length
? `When reporting, include taskIds: ${taskIds.map((id) => `"${id}"`).join(', ')}.`
: null,
`Do not use provider names, runtime names, or team names as memberName; use exactly "${input.memberName}".`,
'Do not reply only with acknowledgement.',
]
: [
'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).',
`Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`,
'Include source="runtime_delivery" in that message_send call.',
input.messageId
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
: null,
'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.',
'You must not end this turn empty.',
'Do not answer only with plain assistant text when agent-teams_message_send is available.',
];
return [ return [
'<opencode_app_message_delivery>', '<opencode_app_message_delivery>',
@ -791,15 +822,8 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
? `<opencode_delivery_context>${deliveryContext}</opencode_delivery_context>` ? `<opencode_delivery_context>${deliveryContext}</opencode_delivery_context>`
: null, : null,
'You are running in OpenCode, not Claude Code or Codex native.', 'You are running in OpenCode, not Claude Code or Codex native.',
'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send (or mcp__agent-teams__message_send if that is the exposed name).', ...responseInstructions,
`Use teamName="${input.teamName}", to="${replyRecipient}", from="${input.memberName}", text, and summary.`,
'Include source="runtime_delivery" in that message_send call.',
input.messageId
? `Include relayOfMessageId="${input.messageId}" in that message_send call.`
: null,
'After the message_send tool call succeeds, stop immediately. Do not send follow-up confirmations or repeat the same answer.',
'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.', 'Do not call runtime_bootstrap_checkin or member_briefing just to answer this delivered app message.',
'Do not answer only with plain assistant text when agent-teams_message_send is available.',
'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.', 'Do not use SendMessage or runtime_deliver_message for ordinary visible replies.',
'Do not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.', 'Do not invent placeholder task labels. If no explicit taskRefs are provided and the reply is not about a real board task, do not prefix text or summary with a # task label; never use #00000000.',
'The inbound app message follows. Treat it as the actual instruction to process now, not as background context.', 'The inbound app message follows. Treat it as the actual instruction to process now, not as background context.',

View file

@ -1310,6 +1310,13 @@ export class HttpAPIClient implements ElectronAPI {
request.memberName request.memberName
)}` )}`
), ),
refreshStatus: (request) =>
this.post(
`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/${encodeURIComponent(
request.memberName
)}/refresh`,
{}
),
getMetrics: (request) => getMetrics: (request) =>
this.get(`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/metrics`), this.get(`/api/teams/${encodeURIComponent(request.teamName)}/member-work-sync/metrics`),
report: (request) => report: (request) =>

View file

@ -52,7 +52,11 @@ export const PaneContent = ({ pane, isPaneFocused }: PaneContentProps): React.JS
{tab.type === 'teams' && <TeamListView />} {tab.type === 'teams' && <TeamListView />}
{tab.type === 'team' && ( {tab.type === 'team' && (
<TabUIProvider tabId={tab.id}> <TabUIProvider tabId={tab.id}>
<TeamDetailView teamName={tab.teamName ?? ''} isPaneFocused={isPaneFocused} /> <TeamDetailView
teamName={tab.teamName ?? ''}
isActive={isActive}
isPaneFocused={isPaneFocused}
/>
</TabUIProvider> </TabUIProvider>
)} )}
{tab.type === 'session' && ( {tab.type === 'session' && (

View file

@ -72,7 +72,8 @@ export const ClaudeLogsPanel = ({
<span className="text-[11px] text-[var(--color-text-muted)]"> <span className="text-[11px] text-[var(--color-text-muted)]">
{data.total > 0 ? ( {data.total > 0 ? (
<> <>
<span className="font-mono">{data.total}</span> lines <span className="font-mono">{data.total}</span> raw line
{data.total === 1 ? '' : 's'}
</> </>
) : isAlive ? ( ) : isAlive ? (
'No logs yet.' 'No logs yet.'

View file

@ -157,6 +157,7 @@ import type { ContextUsageLike } from '@shared/utils/contextMetrics';
interface TeamDetailViewProps { interface TeamDetailViewProps {
teamName: string; teamName: string;
isActive?: boolean;
isPaneFocused?: boolean; isPaneFocused?: boolean;
} }
@ -344,6 +345,33 @@ interface LeadContextBridgeProps {
isThisTabActive: boolean; isThisTabActive: boolean;
} }
const EMPTY_MESSAGES_PANEL_TASKS: TeamTaskWithKanban[] = [];
function buildMessagesPanelTasksSignature(tasks: readonly TeamTaskWithKanban[]): string {
return JSON.stringify(
tasks.map((task) => [
task.id,
task.displayId ?? '',
task.subject,
task.owner ?? '',
task.reviewer ?? '',
task.status,
task.reviewState ?? '',
task.kanbanColumn ?? '',
])
);
}
function useStableMessagesPanelTasks(
tasks: TeamTaskWithKanban[] | undefined
): TeamTaskWithKanban[] {
const sourceTasks = tasks ?? EMPTY_MESSAGES_PANEL_TASKS;
const signature = useMemo(() => buildMessagesPanelTasksSignature(sourceTasks), [sourceTasks]);
// eslint-disable-next-line react-hooks/exhaustive-deps -- sourceTasks identity is gated by render-relevant task fields.
return useMemo(() => sourceTasks, [signature]);
}
// Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet. // Codex/OpenCode lead sessions do not expose the Claude-style context data this panel expects yet.
const LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS = new Set<TeamProviderId>(['codex', 'opencode']); const LEAD_CONTEXT_UNSUPPORTED_PROVIDER_IDS = new Set<TeamProviderId>(['codex', 'opencode']);
@ -377,10 +405,12 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
teamName, teamName,
isTeamProvisioning, isTeamProvisioning,
isTeamAlive, isTeamAlive,
isThisTabActive,
}: { }: {
teamName: string; teamName: string;
isTeamProvisioning: boolean; isTeamProvisioning: boolean;
isTeamAlive?: boolean; isTeamAlive?: boolean;
isThisTabActive: boolean;
}): null { }): null {
const { leadActivity, memberSpawnStatuses, memberSpawnSnapshot, fetchMemberSpawnStatuses } = const { leadActivity, memberSpawnStatuses, memberSpawnSnapshot, fetchMemberSpawnStatuses } =
useStore( useStore(
@ -393,6 +423,8 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
); );
useEffect(() => { useEffect(() => {
if (!isThisTabActive) return;
const hasUnresolvedSpawn = hasUnresolvedMemberSpawnStatus( const hasUnresolvedSpawn = hasUnresolvedMemberSpawnStatus(
memberSpawnStatuses, memberSpawnStatuses,
memberSpawnSnapshot memberSpawnSnapshot
@ -420,6 +452,7 @@ const TeamSpawnStatusWatcher = memo(function TeamSpawnStatusWatcher({
fetchMemberSpawnStatuses, fetchMemberSpawnStatuses,
isTeamAlive, isTeamAlive,
isTeamProvisioning, isTeamProvisioning,
isThisTabActive,
leadActivity, leadActivity,
memberSpawnSnapshot, memberSpawnSnapshot,
memberSpawnStatuses, memberSpawnStatuses,
@ -888,6 +921,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
export const TeamDetailView = memo(function TeamDetailView({ export const TeamDetailView = memo(function TeamDetailView({
teamName, teamName,
isActive = true,
isPaneFocused = false, isPaneFocused = false,
}: TeamDetailViewProps): React.JSX.Element { }: TeamDetailViewProps): React.JSX.Element {
const { isLight } = useTheme(); const { isLight } = useTheme();
@ -1279,8 +1313,7 @@ export const TeamDetailView = memo(function TeamDetailView({
); );
const tabId = useTabIdOptional(); const tabId = useTabIdOptional();
const activeTabId = useStore((s) => s.activeTabId); const isThisTabActive = isActive;
const isThisTabActive = tabId ? activeTabId === tabId : false;
const wasInteractiveRef = useRef(false); const wasInteractiveRef = useRef(false);
// Messages panel resize // Messages panel resize
@ -1324,33 +1357,32 @@ export const TeamDetailView = memo(function TeamDetailView({
useEffect(() => { useEffect(() => {
const wasProvisioning = wasProvisioningRef.current; const wasProvisioning = wasProvisioningRef.current;
wasProvisioningRef.current = isTeamProvisioning; wasProvisioningRef.current = isTeamProvisioning;
if (!isThisTabActive) return;
if (!wasProvisioning && isTeamProvisioning) { if (!wasProvisioning && isTeamProvisioning) {
provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' }); provisioningBannerRef.current?.scrollIntoView({ behavior: 'smooth', block: 'start' });
} }
}, [isTeamProvisioning]); }, [isTeamProvisioning, isThisTabActive]);
const [kanbanSearch, setKanbanSearch] = useState(''); const [kanbanSearch, setKanbanSearch] = useState('');
// Open editor overlay when a file reveal is requested (e.g. from chip click) // Open editor overlay when a file reveal is requested (e.g. from chip click)
const pendingRevealFile = useStore((s) => s.editorPendingRevealFile); const pendingRevealFile = useStore((s) => s.editorPendingRevealFile);
useEffect(() => { useEffect(() => {
if (!isThisTabActive) return;
if (pendingRevealFile && data?.config.projectPath) { if (pendingRevealFile && data?.config.projectPath) {
setEditorOpen(true); setEditorOpen(true);
} }
}, [pendingRevealFile, data?.config.projectPath]); }, [isThisTabActive, pendingRevealFile, data?.config.projectPath]);
useEffect(() => { useEffect(() => {
if (!teamName) { if (!isThisTabActive || !teamName) {
return; return;
} }
void selectTeam(teamName); void selectTeam(teamName);
void fetchDeletedTasks(teamName); void fetchDeletedTasks(teamName);
}, [teamName, selectTeam, fetchDeletedTasks]); }, [isThisTabActive, teamName, selectTeam, fetchDeletedTasks]);
// Recovery: after HMR, all mounted TeamDetailView effects re-run simultaneously. // Re-trigger selectTeam when this visible tab becomes active and store data is stale.
// With CSS display-toggle (all tabs stay mounted), the last selectTeam() call wins
// and other tabs get stuck with mismatched data (permanent skeleton).
// Re-trigger selectTeam when this tab becomes active and store data is stale.
const storedTeamName = data?.teamName; const storedTeamName = data?.teamName;
useEffect(() => { useEffect(() => {
if (!isThisTabActive || !teamName || loading) return; if (!isThisTabActive || !teamName || loading) return;
@ -1387,7 +1419,7 @@ export const TeamDetailView = memo(function TeamDetailView({
// Fetch active teams when launch dialog opens (for conflict warning) // Fetch active teams when launch dialog opens (for conflict warning)
useEffect(() => { useEffect(() => {
if (!launchDialogOpen) return; if (!isThisTabActive || !launchDialogOpen) return;
let cancelled = false; let cancelled = false;
const teamsSnapshot = useStore.getState().teams; const teamsSnapshot = useStore.getState().teams;
void (async () => { void (async () => {
@ -1410,7 +1442,7 @@ export const TeamDetailView = memo(function TeamDetailView({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [launchDialogOpen]); }, [isThisTabActive, launchDialogOpen]);
useEffect(() => { useEffect(() => {
if (kanbanFilterQuery) { if (kanbanFilterQuery) {
@ -1456,7 +1488,7 @@ export const TeamDetailView = memo(function TeamDetailView({
]); ]);
useEffect(() => { useEffect(() => {
if (!projectId) return; if (!isThisTabActive || !projectId) return;
let cancelled = false; let cancelled = false;
setSessionsLoading(true); setSessionsLoading(true);
@ -1485,7 +1517,7 @@ export const TeamDetailView = memo(function TeamDetailView({
return () => { return () => {
cancelled = true; cancelled = true;
}; };
}, [data?.config.leadSessionId, projectId, sessionHistoryKey]); }, [data?.config.leadSessionId, isThisTabActive, projectId, sessionHistoryKey]);
// Live git branch tracking for the lead project and member worktrees // Live git branch tracking for the lead project and member worktrees
const teamProjectPath = data?.config.projectPath?.trim() ?? null; const teamProjectPath = data?.config.projectPath?.trim() ?? null;
@ -1510,7 +1542,11 @@ export const TeamDetailView = memo(function TeamDetailView({
return Array.from(uniquePaths.values()); return Array.from(uniquePaths.values());
}, [members, leadProjectPath]); }, [members, leadProjectPath]);
useBranchSync(branchSyncPaths, { live: true }); const activeBranchSyncPaths = useMemo(
() => (isThisTabActive ? branchSyncPaths : []),
[branchSyncPaths, isThisTabActive]
);
useBranchSync(activeBranchSyncPaths, { live: isThisTabActive });
const trackedBranches = useStore( const trackedBranches = useStore(
useShallow((s) => useShallow((s) =>
Object.fromEntries( Object.fromEntries(
@ -1626,11 +1662,12 @@ export const TeamDetailView = memo(function TeamDetailView({
const activeMembers = useStableActiveMembers(membersWithLiveBranches); const activeMembers = useStableActiveMembers(membersWithLiveBranches);
const kanbanSearchQuery = kanbanSearch.trim();
const isKanbanSearchActive = kanbanSearchQuery.length > 0;
const kanbanDisplayTasks = useMemo(() => { const kanbanDisplayTasks = useMemo(() => {
const query = kanbanSearch.trim(); if (!kanbanSearchQuery) return filteredTasks;
if (!query) return filteredTasks; return filterKanbanTasks(filteredTasks, kanbanSearchQuery);
return filterKanbanTasks(filteredTasks, query); }, [filteredTasks, kanbanSearchQuery]);
}, [filteredTasks, kanbanSearch]);
const activeTeammateCount = useMemo( const activeTeammateCount = useMemo(
() => activeMembers.filter((m) => !isLeadMember(m)).length, () => activeMembers.filter((m) => !isLeadMember(m)).length,
@ -1773,9 +1810,12 @@ export const TeamDetailView = memo(function TeamDetailView({
} }
}, []); }, []);
const handleOpenTask = useCallback((task: TeamTaskWithKanban) => { const handleOpenMessagePanelTask = useCallback(
setSelectedTask(task); (task: TeamTaskWithKanban) => {
}, []); handleOpenTaskById(task.id);
},
[handleOpenTaskById]
);
const handleTaskIdClick = useCallback( const handleTaskIdClick = useCallback(
(taskId: string) => { (taskId: string) => {
@ -1830,6 +1870,7 @@ export const TeamDetailView = memo(function TeamDetailView({
// Pick up pending review request from GlobalTaskDetailDialog // Pick up pending review request from GlobalTaskDetailDialog
useEffect(() => { useEffect(() => {
if (!isThisTabActive) return;
if (!pendingReviewRequest) return; if (!pendingReviewRequest) return;
setReviewDialogState({ setReviewDialogState({
open: true, open: true,
@ -1842,11 +1883,12 @@ export const TeamDetailView = memo(function TeamDetailView({
selectReviewFile(pendingReviewRequest.filePath); selectReviewFile(pendingReviewRequest.filePath);
} }
setPendingReviewRequest(null); setPendingReviewRequest(null);
}, [pendingReviewRequest, selectReviewFile, setPendingReviewRequest]); }, [isThisTabActive, pendingReviewRequest, selectReviewFile, setPendingReviewRequest]);
const pendingTeamSectionFocus = useStore((s) => s.pendingTeamSectionFocus); const pendingTeamSectionFocus = useStore((s) => s.pendingTeamSectionFocus);
const clearTeamSectionFocus = useStore((s) => s.clearTeamSectionFocus); const clearTeamSectionFocus = useStore((s) => s.clearTeamSectionFocus);
useEffect(() => { useEffect(() => {
if (!isThisTabActive) return;
if (pendingTeamSectionFocus?.teamName !== teamName) return; if (pendingTeamSectionFocus?.teamName !== teamName) return;
const sectionId = const sectionId =
@ -1868,11 +1910,12 @@ export const TeamDetailView = memo(function TeamDetailView({
if (!section) return; if (!section) return;
section.dispatchEvent(new CustomEvent('team-section-navigate')); section.dispatchEvent(new CustomEvent('team-section-navigate'));
clearTeamSectionFocus(); clearTeamSectionFocus();
}, [pendingTeamSectionFocus, clearTeamSectionFocus, teamName, data]); }, [pendingTeamSectionFocus, clearTeamSectionFocus, isThisTabActive, teamName, data]);
// Pick up pending member profile request from MemberHoverCard // Pick up pending member profile request from MemberHoverCard
const pendingMemberProfile = useStore((s) => s.pendingMemberProfile); const pendingMemberProfile = useStore((s) => s.pendingMemberProfile);
useEffect(() => { useEffect(() => {
if (!isThisTabActive) return;
if (!pendingMemberProfile || !data) return; if (!pendingMemberProfile || !data) return;
if (pendingMemberProfile.teamName && pendingMemberProfile.teamName !== teamName) return; if (pendingMemberProfile.teamName && pendingMemberProfile.teamName !== teamName) return;
@ -1889,7 +1932,7 @@ export const TeamDetailView = memo(function TeamDetailView({
}); });
} }
useStore.getState().closeMemberProfile(); useStore.getState().closeMemberProfile();
}, [pendingMemberProfile, membersWithLiveBranches, teamName, data]); }, [isThisTabActive, pendingMemberProfile, membersWithLiveBranches, teamName, data]);
const handleDeleteTask = useCallback( const handleDeleteTask = useCallback(
(taskId: string) => { (taskId: string) => {
@ -2004,21 +2047,22 @@ export const TeamDetailView = memo(function TeamDetailView({
})(); })();
}; };
const messagesPanelTasks = useStableMessagesPanelTasks(data?.tasks);
const sharedMessagesPanelProps = useMemo<SharedTeamMessagesPanelProps>( const sharedMessagesPanelProps = useMemo<SharedTeamMessagesPanelProps>(
() => ({ () => ({
teamName, teamName,
onPositionChange: changeMessagesPanelMode, onPositionChange: changeMessagesPanelMode,
mountPoint: messagesPanelMountPoint, mountPoint: messagesPanelMountPoint,
members: activeMembers, members: activeMembers,
tasks: data?.tasks ?? [], tasks: messagesPanelTasks,
isTeamAlive: data?.isAlive, isTeamAlive: data?.isAlive,
timeWindow, timeWindow,
teamSessionIds,
currentLeadSessionId: data?.config.leadSessionId, currentLeadSessionId: data?.config.leadSessionId,
pendingRepliesByMember, pendingRepliesByMember,
onPendingReplyChange: setPendingRepliesByMember, onPendingReplyChange: setPendingRepliesByMember,
onMemberClick: handleSelectMember, onMemberClick: handleSelectMember,
onTaskClick: handleOpenTask, onTaskClick: handleOpenMessagePanelTask,
onCreateTaskFromMessage: handleCreateTaskFromMessage, onCreateTaskFromMessage: handleCreateTaskFromMessage,
onReplyToMessage: handleReplyToMessage, onReplyToMessage: handleReplyToMessage,
onRestartTeam: handleRestartTeam, onRestartTeam: handleRestartTeam,
@ -2029,17 +2073,16 @@ export const TeamDetailView = memo(function TeamDetailView({
activeMembers, activeMembers,
data?.config.leadSessionId, data?.config.leadSessionId,
data?.isAlive, data?.isAlive,
data?.tasks,
handleCreateTaskFromMessage, handleCreateTaskFromMessage,
handleOpenTask, handleOpenMessagePanelTask,
handleReplyToMessage, handleReplyToMessage,
handleRestartTeam, handleRestartTeam,
handleSelectMember, handleSelectMember,
handleTaskIdClick, handleTaskIdClick,
messagesPanelTasks,
messagesPanelMountPoint, messagesPanelMountPoint,
pendingRepliesByMember, pendingRepliesByMember,
teamName, teamName,
teamSessionIds,
timeWindow, timeWindow,
changeMessagesPanelMode, changeMessagesPanelMode,
] ]
@ -2058,6 +2101,7 @@ export const TeamDetailView = memo(function TeamDetailView({
teamName={teamName} teamName={teamName}
isTeamProvisioning={isTeamProvisioning} isTeamProvisioning={isTeamProvisioning}
isTeamAlive={data?.isAlive} isTeamAlive={data?.isAlive}
isThisTabActive={isThisTabActive}
/> />
); );
const teamAgentRuntimeWatcher = ( const teamAgentRuntimeWatcher = (
@ -2485,7 +2529,7 @@ export const TeamDetailView = memo(function TeamDetailView({
icon={<Columns3 size={14} />} icon={<Columns3 size={14} />}
badge={filteredTasks.length} badge={filteredTasks.length}
defaultOpen defaultOpen
forceOpen={kanbanSearch.trim().length > 0} forceOpen={isKanbanSearchActive}
action={ action={
<Button <Button
variant="ghost" variant="ghost"
@ -2510,6 +2554,7 @@ export const TeamDetailView = memo(function TeamDetailView({
sessions={teamSessions} sessions={teamSessions}
leadSessionId={data.config.leadSessionId} leadSessionId={data.config.leadSessionId}
members={activeMembers} members={activeMembers}
forceShowAllTasks={isKanbanSearchActive}
onFilterChange={setKanbanFilter} onFilterChange={setKanbanFilter}
onSortChange={setKanbanSort} onSortChange={setKanbanSort}
toolbarLeft={ toolbarLeft={

View file

@ -11,6 +11,7 @@ import { cn } from '@renderer/lib/utils';
import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers';
import { import {
CheckCircle2, CheckCircle2,
ChevronDown,
ClipboardList, ClipboardList,
Columns3, Columns3,
Eye, Eye,
@ -77,6 +78,8 @@ interface KanbanBoardProps {
sessions: Session[]; sessions: Session[];
leadSessionId?: string; leadSessionId?: string;
members: ResolvedTeamMember[]; members: ResolvedTeamMember[];
/** Shows all cards when another UI flow, such as search, must not hide matches. */
forceShowAllTasks?: boolean;
onFilterChange: (filter: KanbanFilterState) => void; onFilterChange: (filter: KanbanFilterState) => void;
onSortChange: (sort: KanbanSortState) => void; onSortChange: (sort: KanbanSortState) => void;
onRequestReview: (taskId: string) => void; onRequestReview: (taskId: string) => void;
@ -107,6 +110,8 @@ interface KanbanBoardProps {
type KanbanViewMode = 'grid' | 'columns'; type KanbanViewMode = 'grid' | 'columns';
const SCROLLABLE_OVERFLOW_VALUES = new Set(['auto', 'scroll', 'overlay']); const SCROLLABLE_OVERFLOW_VALUES = new Set(['auto', 'scroll', 'overlay']);
const INITIAL_VISIBLE_TASKS_PER_COLUMN = 20;
const LOAD_MORE_TASKS_PER_COLUMN = 20;
const COLUMNS: { id: KanbanColumnId; title: string }[] = [ const COLUMNS: { id: KanbanColumnId; title: string }[] = [
{ id: 'todo', title: 'TODO' }, { id: 'todo', title: 'TODO' },
@ -320,6 +325,7 @@ export const KanbanBoard = memo(function KanbanBoard({
sessions, sessions,
leadSessionId, leadSessionId,
members, members,
forceShowAllTasks = false,
onFilterChange, onFilterChange,
onSortChange, onSortChange,
onRequestReview, onRequestReview,
@ -344,9 +350,13 @@ export const KanbanBoard = memo(function KanbanBoard({
const [viewMode, setViewMode] = useState<KanbanViewMode>('grid'); const [viewMode, setViewMode] = useState<KanbanViewMode>('grid');
const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null); const [gridPrimaryColumnWidth, setGridPrimaryColumnWidth] = useState<number | null>(null);
const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS); const [gridSkeletonDelayMs, setGridSkeletonDelayMs] = useState(SKELETON_HIDE_DELAY_MS);
const [visibleTaskLimitsByColumn, setVisibleTaskLimitsByColumn] = useState<
Partial<Record<KanbanColumnId, number>>
>({});
const hasReviewers = kanbanState.reviewers.length > 0; const hasReviewers = kanbanState.reviewers.length > 0;
const enableTaskSorting = const enableTaskSorting =
viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual'; viewMode === 'columns' && !!onColumnOrderChange && sort.field === 'manual';
const shouldLimitTaskCards = !forceShowAllTasks && !enableTaskSorting;
const stableTaskMapRef = useRef<{ const stableTaskMapRef = useRef<{
signatures: string[]; signatures: string[];
@ -393,6 +403,90 @@ export const KanbanBoard = memo(function KanbanBoard({
return result; return result;
}, [grouped, kanbanState.columnOrder, sort.field]); }, [grouped, kanbanState.columnOrder, sort.field]);
const filterOwnerKey = useMemo(
() =>
Array.from(filter.selectedOwners)
.sort((a, b) => a.localeCompare(b))
.join('\0'),
[filter.selectedOwners]
);
const filterColumnKey = useMemo(
() =>
Array.from(filter.columns)
.sort((a, b) => a.localeCompare(b))
.join('\0'),
[filter.columns]
);
useEffect(() => {
setVisibleTaskLimitsByColumn({});
}, [teamName, viewMode, sort.field, filter.sessionId, filterOwnerKey, filterColumnKey]);
const getVisibleTaskLimit = useCallback(
(columnId: KanbanColumnId) =>
visibleTaskLimitsByColumn[columnId] ?? INITIAL_VISIBLE_TASKS_PER_COLUMN,
[visibleTaskLimitsByColumn]
);
const revealNextTasks = useCallback((columnId: KanbanColumnId, totalTasks: number) => {
setVisibleTaskLimitsByColumn((prev) => {
const current = prev[columnId] ?? INITIAL_VISIBLE_TASKS_PER_COLUMN;
return {
...prev,
[columnId]: Math.min(current + LOAD_MORE_TASKS_PER_COLUMN, totalTasks),
};
});
}, []);
const renderableColumnTasks = useCallback(
(columnId: KanbanColumnId, columnTasks: TeamTask[]) => {
if (!shouldLimitTaskCards) {
return columnTasks;
}
return columnTasks.slice(0, getVisibleTaskLimit(columnId));
},
[getVisibleTaskLimit, shouldLimitTaskCards]
);
const handleScrollToTask = useCallback(
(taskId: string) => {
if (!onScrollToTask) {
return;
}
if (!shouldLimitTaskCards) {
onScrollToTask(taskId);
return;
}
for (const column of COLUMNS) {
const columnTasks = groupedOrdered.get(column.id) ?? [];
const taskIndex = columnTasks.findIndex((task) => task.id === taskId);
if (taskIndex === -1) {
continue;
}
const currentLimit = getVisibleTaskLimit(column.id);
if (taskIndex < currentLimit) {
onScrollToTask(taskId);
return;
}
setVisibleTaskLimitsByColumn((prev) => ({
...prev,
[column.id]: Math.max(prev[column.id] ?? INITIAL_VISIBLE_TASKS_PER_COLUMN, taskIndex + 1),
}));
window.requestAnimationFrame(() => {
window.requestAnimationFrame(() => onScrollToTask(taskId));
});
return;
}
onScrollToTask(taskId);
},
[getVisibleTaskLimit, groupedOrdered, onScrollToTask, shouldLimitTaskCards]
);
const sensors = useSensors( const sensors = useSensors(
useSensor(PointerSensor, { useSensor(PointerSensor, {
activationConstraint: { distance: 8 }, activationConstraint: { distance: 8 },
@ -451,12 +545,30 @@ export const KanbanBoard = memo(function KanbanBoard({
) )
); );
} }
const visibleTasks = renderableColumnTasks(columnId, columnTasks);
const hiddenTaskCount = Math.max(0, columnTasks.length - visibleTasks.length);
const nextRevealCount = Math.min(LOAD_MORE_TASKS_PER_COLUMN, hiddenTaskCount);
const showMoreButton =
hiddenTaskCount > 0 ? (
<button
type="button"
onClick={() => revealNextTasks(columnId, columnTasks.length)}
className="flex w-full items-center justify-center gap-1.5 rounded-md border border-[var(--color-border)] bg-[var(--color-surface)] p-2.5 text-xs text-[var(--color-text-muted)] transition-colors hover:border-[var(--color-border-emphasis)] hover:text-[var(--color-text-secondary)]"
>
<ChevronDown size={13} />
Show {nextRevealCount} more
<span className="text-[10px] text-[var(--color-text-muted)]">
{hiddenTaskCount} hidden
</span>
</button>
) : null;
if (enableTaskSorting) { if (enableTaskSorting) {
const itemIds = columnTasks.map((t) => t.id); const itemIds = visibleTasks.map((t) => t.id);
return ( return (
<> <>
<SortableContext items={itemIds} strategy={verticalListSortingStrategy}> <SortableContext items={itemIds} strategy={verticalListSortingStrategy}>
{columnTasks.map((task) => ( {visibleTasks.map((task) => (
<SortableKanbanTaskCard <SortableKanbanTaskCard
key={task.id} key={task.id}
task={task} task={task}
@ -473,20 +585,21 @@ export const KanbanBoard = memo(function KanbanBoard({
onStartTask={onStartTask} onStartTask={onStartTask}
onCompleteTask={onCompleteTask} onCompleteTask={onCompleteTask}
onCancelTask={onCancelTask} onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask} onScrollToTask={handleScrollToTask}
onTaskClick={onTaskClick} onTaskClick={onTaskClick}
onViewChanges={onViewChanges} onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask} onDeleteTask={onDeleteTask}
/> />
))} ))}
</SortableContext> </SortableContext>
{showMoreButton}
{addButton} {addButton}
</> </>
); );
} }
return ( return (
<> <>
{columnTasks.map((task) => ( {visibleTasks.map((task) => (
<KanbanTaskCard <KanbanTaskCard
key={task.id} key={task.id}
task={task} task={task}
@ -504,18 +617,20 @@ export const KanbanBoard = memo(function KanbanBoard({
onStartTask={onStartTask} onStartTask={onStartTask}
onCompleteTask={onCompleteTask} onCompleteTask={onCompleteTask}
onCancelTask={onCancelTask} onCancelTask={onCancelTask}
onScrollToTask={onScrollToTask} onScrollToTask={handleScrollToTask}
onTaskClick={onTaskClick} onTaskClick={onTaskClick}
onViewChanges={onViewChanges} onViewChanges={onViewChanges}
onDeleteTask={onDeleteTask} onDeleteTask={onDeleteTask}
/> />
))} ))}
{showMoreButton}
{addButton} {addButton}
</> </>
); );
}, },
[ [
enableTaskSorting, enableTaskSorting,
handleScrollToTask,
hasReviewers, hasReviewers,
kanbanState, kanbanState,
memberColorMap, memberColorMap,
@ -527,10 +642,11 @@ export const KanbanBoard = memo(function KanbanBoard({
onMoveBackToDone, onMoveBackToDone,
onRequestChanges, onRequestChanges,
onRequestReview, onRequestReview,
onScrollToTask,
onStartTask, onStartTask,
onTaskClick, onTaskClick,
onViewChanges, onViewChanges,
renderableColumnTasks,
revealNextTasks,
taskMap, taskMap,
teamName, teamName,
] ]
@ -623,13 +739,21 @@ export const KanbanBoard = memo(function KanbanBoard({
bodyBg: accent.bodyBg, bodyBg: accent.bodyBg,
content: renderCards(column.id, columnTasks), content: renderCards(column.id, columnTasks),
showAddButton: columnSupportsAddButton(column.id, onAddTask), showAddButton: columnSupportsAddButton(column.id, onAddTask),
skeletonCards: columnTasks.map((task) => ({ skeletonCards: renderableColumnTasks(column.id, columnTasks).map((task) => ({
key: task.id, key: task.id,
height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers), height: estimateGridSkeletonCardHeight(task, column.id, kanbanState, hasReviewers),
})), })),
}; };
}), }),
[visibleColumns, groupedOrdered, renderCards, onAddTask, kanbanState, hasReviewers] [
visibleColumns,
groupedOrdered,
renderCards,
onAddTask,
renderableColumnTasks,
kanbanState,
hasReviewers,
]
); );
const boardContent = ( const boardContent = (

View file

@ -1,4 +1,5 @@
import { import {
type ComponentProps,
memo, memo,
type RefObject, type RefObject,
useCallback, useCallback,
@ -216,6 +217,109 @@ export function hasVisibleReplyForSendMessageDiagnostics(
}); });
} }
const MessagesComposerSection = memo(MessageComposer);
const MessagesStatusSection = memo(StatusBlock);
type MessagesTimelineSectionProps = ComponentProps<typeof ActivityTimeline> & {
hasMore: boolean;
loadingOlderMessages: boolean;
onLoadOlderMessages: () => void;
expandedItem: TimelineItem | null;
expandedItemKey: string | null;
onExpandDialogChange: (open: boolean) => void;
};
const MessagesTimelineSection = memo(function MessagesTimelineSection({
hasMore,
loadingOlderMessages,
onLoadOlderMessages,
expandedItem,
expandedItemKey,
onExpandDialogChange,
messages,
teamName,
members,
readState,
allCollapsed,
expandOverrides,
onToggleExpandOverride,
currentLeadSessionId,
isTeamAlive,
leadActivity,
leadContextUpdatedAt,
teamNames,
teamColorByName,
onTeamClick,
onMemberClick,
onCreateTaskFromMessage,
onReplyToMessage,
onMessageVisible,
onRestartTeam,
onTaskIdClick,
onExpandItem,
onExpandContent,
viewport,
}: MessagesTimelineSectionProps): React.JSX.Element {
return (
<>
<ActivityTimeline
messages={messages}
teamName={teamName}
members={members}
readState={readState}
allCollapsed={allCollapsed}
expandOverrides={expandOverrides}
onToggleExpandOverride={onToggleExpandOverride}
currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={onMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={onExpandItem}
onExpandContent={onExpandContent}
viewport={viewport}
/>
{hasMore && (
<div className="flex justify-center py-2">
<Button
variant="ghost"
size="sm"
className="text-xs text-text-muted"
aria-busy={loadingOlderMessages}
disabled={loadingOlderMessages}
onClick={onLoadOlderMessages}
>
Load older messages
</Button>
</div>
)}
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItemKey !== null}
onOpenChange={onExpandDialogChange}
teamName={teamName}
members={members}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMemberClick={onMemberClick}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={onTeamClick}
/>
</>
);
});
export const MessagesPanel = memo(function MessagesPanel({ export const MessagesPanel = memo(function MessagesPanel({
teamName, teamName,
position, position,
@ -282,8 +386,10 @@ export const MessagesPanel = memo(function MessagesPanel({
await loadOlderTeamMessages(teamName); await loadOlderTeamMessages(teamName);
}, [loadOlderTeamMessages, messagesState, teamName]); }, [loadOlderTeamMessages, messagesState, teamName]);
const messagesLoading = const handleLoadOlderMessagesClick = useCallback(() => {
(messagesState?.loadingHead ?? false) || (messagesState?.loadingOlder ?? false); void loadOlderMessages();
}, [loadOlderMessages]);
const loadingOlderMessages = messagesState?.loadingOlder ?? false; const loadingOlderMessages = messagesState?.loadingOlder ?? false;
const hasMore = messagesState?.hasMore ?? false; const hasMore = messagesState?.hasMore ?? false;
const effectiveMessages = messages; const effectiveMessages = messages;
@ -723,6 +829,99 @@ export const MessagesPanel = memo(function MessagesPanel({
); );
}, [bottomSheetSnapIndex]); }, [bottomSheetSnapIndex]);
const defaultComposerSection = (
<MessagesComposerSection
teamName={teamName}
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={effectiveSendMessageWarning}
sendDebugDetails={effectiveSendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
);
const compactComposerSection = (
<MessagesComposerSection
teamName={teamName}
layout="compact"
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={effectiveSendMessageWarning}
sendDebugDetails={effectiveSendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
);
const inlineStatusSection = (
<MessagesStatusSection
members={members}
tasks={tasks}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="inline"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
);
const sidebarStatusSection = (
<MessagesStatusSection
members={members}
tasks={tasks}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="sidebar"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
);
const timelineSection = (
<MessagesTimelineSection
messages={activityTimelineMessages}
teamName={teamName}
members={members}
readState={readState}
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={handleMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
hasMore={hasMore}
loadingOlderMessages={loadingOlderMessages}
onLoadOlderMessages={handleLoadOlderMessagesClick}
expandedItem={expandedItem}
expandedItemKey={expandedItemKey}
onExpandDialogChange={handleExpandDialogChange}
/>
);
// ---- Shared content (used in both modes) ---- // ---- Shared content (used in both modes) ----
const searchAndFilterControls = ( const searchAndFilterControls = (
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
@ -785,83 +984,9 @@ export const MessagesPanel = memo(function MessagesPanel({
const messagesContent = ( const messagesContent = (
<div className="pb-14"> <div className="pb-14">
<MessageComposer {defaultComposerSection}
teamName={teamName} {inlineStatusSection}
members={members} {timelineSection}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={effectiveSendMessageWarning}
sendDebugDetails={effectiveSendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
<StatusBlock
members={members}
tasks={tasks}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="inline"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
<ActivityTimeline
messages={activityTimelineMessages}
teamName={teamName}
members={members}
readState={readState}
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={handleMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/>
{hasMore && (
<div className="flex justify-center py-2">
<Button
variant="ghost"
size="sm"
className="text-xs text-text-muted"
aria-busy={loadingOlderMessages}
disabled={loadingOlderMessages}
onClick={() => void loadOlderMessages()}
>
Load older messages
</Button>
</div>
)}
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItemKey !== null}
onOpenChange={handleExpandDialogChange}
teamName={teamName}
members={members}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMemberClick={onMemberClick}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
/>
</div> </div>
); );
@ -972,84 +1097,10 @@ export const MessagesPanel = memo(function MessagesPanel({
onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)} onScroll={(e) => setMessagesScrollTop(e.currentTarget.scrollTop)}
> >
<div className="pl-3"> <div className="pl-3">
<MessageComposer {defaultComposerSection}
teamName={teamName} {sidebarStatusSection}
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={effectiveSendMessageWarning}
sendDebugDetails={effectiveSendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
<StatusBlock
members={members}
tasks={tasks}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="sidebar"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>{' '}
</div> </div>
<ActivityTimeline {timelineSection}
messages={activityTimelineMessages}
teamName={teamName}
members={members}
readState={readState}
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={handleMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/>
{hasMore && (
<div className="flex justify-center py-2">
<Button
variant="ghost"
size="sm"
className="text-xs text-text-muted"
aria-busy={loadingOlderMessages}
disabled={loadingOlderMessages}
onClick={() => void loadOlderMessages()}
>
Load older messages
</Button>
</div>
)}
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItemKey !== null}
onOpenChange={handleExpandDialogChange}
teamName={teamName}
members={members}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMemberClick={onMemberClick}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
/>
</div> </div>
</div> </div>
); );
@ -1256,91 +1307,10 @@ export const MessagesPanel = memo(function MessagesPanel({
{searchAndFilterControls} {searchAndFilterControls}
</div> </div>
)} )}
<div className="p-3"> <div className="p-3">{compactComposerSection}</div>
<MessageComposer
teamName={teamName}
layout="compact"
members={members}
isTeamAlive={isTeamAlive}
sending={sendingMessage}
sendError={sendMessageError}
sendWarning={effectiveSendMessageWarning}
sendDebugDetails={effectiveSendMessageDebugDetails}
lastResult={lastSendMessageResult}
textareaRef={composerTextareaRef}
onSend={handleSend}
onCrossTeamSend={handleCrossTeamSend}
/>
</div>
</div> </div>
<div className="shrink-0 px-3 pt-2"> <div className="shrink-0 px-3 pt-2">{inlineStatusSection}</div>
<StatusBlock <div className="flex-1 px-3 pb-4 pt-2">{timelineSection}</div>
members={members}
tasks={tasks}
messages={effectiveMessages}
pendingRepliesByMember={pendingRepliesByMember}
layout="flow"
position="inline"
onMemberClick={onMemberClick}
onTaskClick={onTaskClick}
/>
</div>
<div className="flex-1 px-3 pb-4 pt-2">
<ActivityTimeline
messages={activityTimelineMessages}
teamName={teamName}
members={members}
readState={readState}
allCollapsed={messagesCollapsed}
expandOverrides={expandedSet}
onToggleExpandOverride={toggleExpandOverride}
currentLeadSessionId={currentLeadSessionId}
isTeamAlive={isTeamAlive}
leadActivity={leadActivity}
leadContextUpdatedAt={leadContextUpdatedAt}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
onMemberClick={onMemberClick}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMessageVisible={handleMessageVisible}
onRestartTeam={onRestartTeam}
onTaskIdClick={onTaskIdClick}
onExpandItem={handleExpandItem}
onExpandContent={handleExpandContent}
viewport={activityTimelineViewport}
/>
{hasMore && (
<div className="flex justify-center py-2">
<Button
variant="ghost"
size="sm"
className="text-xs text-text-muted"
aria-busy={loadingOlderMessages}
disabled={loadingOlderMessages}
onClick={() => void loadOlderMessages()}
>
Load older messages
</Button>
</div>
)}
</div>
<MessageExpandDialog
expandedItem={expandedItem}
open={expandedItemKey !== null}
onOpenChange={handleExpandDialogChange}
teamName={teamName}
members={members}
onCreateTaskFromMessage={onCreateTaskFromMessage}
onReplyToMessage={onReplyToMessage}
onMemberClick={onMemberClick}
onTaskIdClick={onTaskIdClick}
onRestartTeam={onRestartTeam}
teamNames={teamNames}
teamColorByName={teamColorByName}
onTeamClick={openTeamTab}
/>
</Sheet.Content> </Sheet.Content>
)} )}
</Sheet.Container> </Sheet.Container>

View file

@ -66,12 +66,12 @@ export const StatusBlock = ({
return hasActiveTasks; return hasActiveTasks;
}, [hasActiveTasks, hasPendingReplies]); }, [hasActiveTasks, hasPendingReplies]);
// Only run the 1-second timer when the block actually has content to show. // Only pending reply TTL labels need a 1-second refresh.
useEffect(() => { useEffect(() => {
if (!hasItems) return; if (!hasPendingReplies) return;
const id = window.setInterval(() => setNowMs(Date.now()), 1000); const id = window.setInterval(() => setNowMs(Date.now()), 1000);
return () => window.clearInterval(id); return () => window.clearInterval(id);
}, [hasItems]); }, [hasPendingReplies]);
if (!hasItems) return null; if (!hasItems) return null;

View file

@ -135,8 +135,11 @@ function buildRuntimeBackedDisplayRow(
const hasErrorDiagnostic = runtime.runtimeDiagnosticSeverity === 'error'; const hasErrorDiagnostic = runtime.runtimeDiagnosticSeverity === 'error';
const spawnDegradation = getSpawnDegradation(spawn); const spawnDegradation = getSpawnDegradation(spawn);
const state = getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null); const state = getRuntimeBackedState(runtime, hasErrorDiagnostic, spawnDegradation != null);
const degradedReason = spawnDegradation
? withLiveProcessContext(spawnDegradation.reason, runtime)
: undefined;
const stateReason = const stateReason =
spawnDegradation?.reason ?? degradedReason ??
runtime.runtimeDiagnostic ?? runtime.runtimeDiagnostic ??
(runtime.alive === true ? 'Runtime heartbeat is alive' : 'Runtime heartbeat is not alive'); (runtime.alive === true ? 'Runtime heartbeat is alive' : 'Runtime heartbeat is not alive');
@ -151,7 +154,10 @@ function buildRuntimeBackedDisplayRow(
laneId: runtime.laneId, laneId: runtime.laneId,
laneKind: runtime.laneKind, laneKind: runtime.laneKind,
runtimeModel: runtime.runtimeModel, runtimeModel: runtime.runtimeModel,
diagnostic: spawnDegradation?.diagnostic ?? runtime.runtimeDiagnostic, diagnostic:
spawnDegradation && degradedReason
? withLiveProcessContext(spawnDegradation.diagnostic ?? degradedReason, runtime)
: runtime.runtimeDiagnostic,
diagnosticSeverity: spawnDegradation?.diagnosticSeverity ?? runtime.runtimeDiagnosticSeverity, diagnosticSeverity: spawnDegradation?.diagnosticSeverity ?? runtime.runtimeDiagnosticSeverity,
pidLabel: formatRuntimePidLabel(runtime), pidLabel: formatRuntimePidLabel(runtime),
actionsAllowed: false, actionsAllowed: false,
@ -213,6 +219,13 @@ function getRuntimeBackedState(
return runtime.alive === true ? 'running' : 'stopped'; return runtime.alive === true ? 'running' : 'stopped';
} }
function withLiveProcessContext(reason: string, runtime: TeamAgentRuntimeEntry): string {
if (runtime.alive !== true || /process is still alive/i.test(reason)) {
return reason;
}
return `${reason}. Process is still alive.`;
}
function buildSpawnBackedDisplayRow( function buildSpawnBackedDisplayRow(
memberName: string, memberName: string,
spawn: MemberSpawnStatusEntry spawn: MemberSpawnStatusEntry

View file

@ -30,6 +30,9 @@ function formatOpenCodeRuntimeDeliveryFailureReason(reason: string | null | unde
if (normalized === 'empty_assistant_turn') { if (normalized === 'empty_assistant_turn') {
return 'OpenCode returned an empty assistant turn.'; return 'OpenCode returned an empty assistant turn.';
} }
if (normalized === 'prompt_delivered_no_assistant_message') {
return 'OpenCode accepted the prompt, but no assistant turn was recorded.';
}
return ''; return '';
} }

View file

@ -64,6 +64,13 @@ interface CodexNativeJsonEvent {
result?: unknown; result?: unknown;
error?: unknown; error?: unknown;
status?: string; status?: string;
command?: string;
aggregated_output?: string;
output?: string;
stderr?: string;
exit_code?: number;
exitCode?: number;
changes?: unknown;
}; };
usage?: { usage?: {
input_tokens?: number; input_tokens?: number;
@ -165,7 +172,17 @@ function getCodexToolDisplayName(serverName: string, toolName: string): string {
return serverName === 'agent-teams' ? `agent-teams_${toolName}` : `${serverName}_${toolName}`; return serverName === 'agent-teams' ? `agent-teams_${toolName}` : `${serverName}_${toolName}`;
} }
function createCodexToolItem( function readRawString(record: Record<string, unknown> | undefined, key: string): string | null {
const value = record?.[key];
return typeof value === 'string' ? value : null;
}
function readFiniteNumber(record: Record<string, unknown> | undefined, key: string): number | null {
const value = record?.[key];
return typeof value === 'number' && Number.isFinite(value) ? value : null;
}
function createCodexMcpToolItem(
event: CodexNativeJsonEvent, event: CodexNativeJsonEvent,
timestamp: Date, timestamp: Date,
lineIndex: number lineIndex: number
@ -210,6 +227,141 @@ function createCodexToolItem(
return { type: 'tool', tool: linkedTool }; return { type: 'tool', tool: linkedTool };
} }
function createCodexCommandExecutionToolItem(
event: CodexNativeJsonEvent,
timestamp: Date,
lineIndex: number
): AIGroupDisplayItem | null {
const item = event.item;
if (
(event.type !== 'item.started' && event.type !== 'item.completed') ||
item?.type !== 'command_execution'
) {
return null;
}
const isCompleted = event.type === 'item.completed';
const itemRecord = asRecord(item) ?? {};
const command = readRawString(itemRecord, 'command') ?? '';
const status =
typeof item.status === 'string' && item.status.trim()
? item.status
: isCompleted
? 'unknown'
: 'in_progress';
const exitCode =
readFiniteNumber(itemRecord, 'exit_code') ?? readFiniteNumber(itemRecord, 'exitCode');
const output =
readRawString(itemRecord, 'aggregated_output') ??
readRawString(itemRecord, 'output') ??
readRawString(itemRecord, 'stderr') ??
'';
const input = { command };
const linkedTool: LinkedToolItem = {
id: item.id ?? `codex-command-L${lineIndex}`,
name: 'Bash',
input,
inputPreview: getToolSummary('Bash', input),
startTime: timestamp,
isOrphaned: !isCompleted,
};
if (isCompleted) {
const isError =
status === 'failed' || status === 'declined' || (exitCode !== null && exitCode !== 0);
linkedTool.endTime = timestamp;
linkedTool.isOrphaned = false;
linkedTool.result = {
content: output,
isError,
};
linkedTool.outputPreview = output || undefined;
}
return { type: 'tool', tool: linkedTool };
}
function getFirstFileChangePath(changes: unknown[]): string {
for (const change of changes) {
const record = asRecord(change);
if (typeof record?.path === 'string' && record.path.trim()) {
return record.path;
}
}
return '';
}
function formatFileChangesResult(changes: unknown[]): string {
const rows = changes.map((change) => {
const record = asRecord(change);
const path =
typeof record?.path === 'string' && record.path.trim() ? record.path : '(unknown path)';
const kind = typeof record?.kind === 'string' && record.kind.trim() ? record.kind : 'update';
return `- ${path} (${kind})`;
});
return ['File changes:', ...rows].join('\n');
}
function createCodexFileChangeToolItem(
event: CodexNativeJsonEvent,
timestamp: Date,
lineIndex: number
): AIGroupDisplayItem | null {
const item = event.item;
if (
(event.type !== 'item.started' && event.type !== 'item.completed') ||
item?.type !== 'file_change'
) {
return null;
}
const isCompleted = event.type === 'item.completed';
const changes = Array.isArray(item.changes) ? item.changes : [];
const input = {
file_path: getFirstFileChangePath(changes),
changes,
};
const status =
typeof item.status === 'string' && item.status.trim()
? item.status
: isCompleted
? 'unknown'
: 'in_progress';
const linkedTool: LinkedToolItem = {
id: item.id ?? `codex-file-change-L${lineIndex}`,
name: 'Edit',
input,
inputPreview: getToolSummary('Edit', input),
startTime: timestamp,
isOrphaned: !isCompleted,
};
if (isCompleted) {
const resultContent = formatFileChangesResult(changes);
linkedTool.endTime = timestamp;
linkedTool.isOrphaned = false;
linkedTool.result = {
content: resultContent,
isError: status === 'failed',
};
linkedTool.outputPreview = resultContent;
}
return { type: 'tool', tool: linkedTool };
}
function createCodexToolItem(
event: CodexNativeJsonEvent,
timestamp: Date,
lineIndex: number
): AIGroupDisplayItem | null {
return (
createCodexMcpToolItem(event, timestamp, lineIndex) ??
createCodexCommandExecutionToolItem(event, timestamp, lineIndex) ??
createCodexFileChangeToolItem(event, timestamp, lineIndex)
);
}
function codexNativeEventToDisplayItems( function codexNativeEventToDisplayItems(
parsed: unknown, parsed: unknown,
timestamp: Date, timestamp: Date,

View file

@ -624,6 +624,7 @@ export interface TeamsAPI {
export interface MemberWorkSyncElectronApi { export interface MemberWorkSyncElectronApi {
getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>; getStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
refreshStatus(request: MemberWorkSyncStatusRequest): Promise<MemberWorkSyncStatus>;
getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>; getMetrics(request: MemberWorkSyncMetricsRequest): Promise<MemberWorkSyncTeamMetrics>;
report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>; report(request: MemberWorkSyncReportRequest): Promise<MemberWorkSyncReportResult>;
} }

View file

@ -712,6 +712,7 @@ export interface SendMessageResult {
| 'permission_blocked' | 'permission_blocked'
| 'tool_error' | 'tool_error'
| 'empty_assistant_turn' | 'empty_assistant_turn'
| 'prompt_delivered_no_assistant_message'
| 'session_stale' | 'session_stale'
| 'session_error' | 'session_error'
| 'reconcile_failed'; | 'reconcile_failed';

View file

@ -113,12 +113,48 @@ describe('buildActionableWorkAgenda', () => {
expect(agenda.items).toEqual([]); expect(agenda.items).toEqual([]);
}); });
it('projects clarification and blocked dependency work for the owner', () => { it('prefers current kanban reviewer over older review history while task remains in review', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'carol',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'alice' }, { name: 'bob' }, { name: 'carol' }],
kanbanReviewersByTaskId: { 'task-1': 'carol' },
tasks: [
{
id: 'task-1',
subject: 'Review me',
status: 'in_progress',
owner: 'bob',
reviewState: 'review',
historyEvents: [
{
id: 'evt-1',
type: 'review_started',
timestamp: '2026-04-29T00:00:00.000Z',
actor: 'alice',
},
],
},
],
hash,
});
expect(agenda.items).toHaveLength(1);
expect(agenda.items[0]).toMatchObject({
taskId: 'task-1',
kind: 'review',
assignee: 'carol',
evidence: { reviewer: 'carol' },
});
});
it('does not nudge owners while work is waiting on user or unfinished dependencies', () => {
const agenda = buildActionableWorkAgenda({ const agenda = buildActionableWorkAgenda({
teamName: 'team-a', teamName: 'team-a',
memberName: 'bob', memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z', generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'bob' }], members: [{ name: 'bob' }, { name: 'team-lead', agentType: 'team-lead' }],
tasks: [ tasks: [
{ {
id: 'task-1', id: 'task-1',
@ -127,20 +163,106 @@ describe('buildActionableWorkAgenda', () => {
owner: 'bob', owner: 'bob',
needsClarification: 'user', needsClarification: 'user',
}, },
{
id: 'task-3',
displayId: '#33333333',
subject: 'Dependency',
status: 'in_progress',
owner: 'alice',
},
{ {
id: 'task-2', id: 'task-2',
subject: 'Blocked', subject: 'Waiting dependency',
status: 'in_progress', status: 'in_progress',
owner: 'bob', owner: 'bob',
blockedBy: ['task-3'], blockedBy: ['#33333333'],
}, },
], ],
hash, hash,
}); });
expect(agenda.items.map((item) => [item.taskId, item.kind, item.priority])).toEqual([ expect(agenda.items).toEqual([]);
['task-1', 'clarification', 'needs_clarification'], });
['task-2', 'blocked_dependency', 'blocked'],
it('does not project display-id dependencies as broken when the dependency exists', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'team-lead',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'bob' }, { name: 'team-lead', agentType: 'team-lead' }],
tasks: [
{
id: 'task-dep',
displayId: '#33333333',
subject: 'Existing dependency',
status: 'in_progress',
owner: 'alice',
},
{
id: 'task-2',
subject: 'Waiting dependency',
status: 'in_progress',
owner: 'bob',
blockedBy: ['33333333'],
},
],
hash,
});
expect(agenda.items).toEqual([]);
});
it('projects lead-owned oversight for lead clarification and broken dependencies', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'team-lead',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'bob' }, { name: 'team-lead', agentType: 'team-lead' }],
tasks: [
{
id: 'task-1',
subject: 'Need lead',
status: 'in_progress',
owner: 'bob',
needsClarification: 'lead',
},
{
id: 'task-2',
subject: 'Broken dependency',
status: 'in_progress',
owner: 'bob',
blockedBy: ['missing-task'],
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
['task-1', 'clarification', 'task_needs_lead_clarification'],
['task-2', 'blocked_dependency', 'task_has_broken_dependency'],
]);
});
it('treats needsFix as owner work', () => {
const agenda = buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'bob',
generatedAt: '2026-04-29T00:00:00.000Z',
members: [{ name: 'bob' }],
tasks: [
{
id: 'task-1',
subject: 'Fix review',
status: 'in_progress',
owner: 'bob',
reviewState: 'needsFix',
},
],
hash,
});
expect(agenda.items.map((item) => [item.taskId, item.kind, item.reason])).toEqual([
['task-1', 'work', 'review_changes_requested'],
]); ]);
}); });

View file

@ -15,7 +15,35 @@ function agendaWithWork() {
memberName: 'bob', memberName: 'bob',
generatedAt: nowIso, generatedAt: nowIso,
members: [{ name: 'bob' }], members: [{ name: 'bob' }],
tasks: [{ id: 'task-1', subject: 'Work', status: 'pending', owner: 'bob' }], tasks: [
{
id: 'task-1',
displayId: '#11111111',
subject: 'Work',
status: 'pending',
owner: 'bob',
},
],
hash,
});
}
function leadAgendaWithBrokenDependency() {
return buildActionableWorkAgenda({
teamName: 'team-a',
memberName: 'team-lead',
generatedAt: nowIso,
members: [{ name: 'team-lead', agentType: 'team-lead' }, { name: 'bob' }],
tasks: [
{
id: 'task-2',
displayId: '#22222222',
subject: 'Blocked work',
status: 'pending',
owner: 'bob',
blockedBy: ['missing-task'],
},
],
hash, hash,
}); });
} }
@ -40,6 +68,39 @@ describe('validateMemberWorkSyncReport', () => {
expect(result.expiresAt).toBe('2026-04-29T00:15:00.000Z'); expect(result.expiresAt).toBe('2026-04-29T00:15:00.000Z');
}); });
it('accepts display task ids for current agenda references', () => {
const agenda = agendaWithWork();
const withHash = validateMemberWorkSyncReport({
request: {
teamName: 'team-a',
memberName: 'bob',
state: 'still_working',
agendaFingerprint: agenda.fingerprint,
taskIds: ['#11111111'],
},
agenda,
nowIso,
activeMemberNames: ['bob'],
tokenValidation: validToken,
});
const withoutHash = validateMemberWorkSyncReport({
request: {
teamName: 'team-a',
memberName: 'bob',
state: 'still_working',
agendaFingerprint: agenda.fingerprint,
taskIds: ['11111111'],
},
agenda,
nowIso,
activeMemberNames: ['bob'],
tokenValidation: validToken,
});
expect(withHash.ok).toBe(true);
expect(withoutHash.ok).toBe(true);
});
it('rejects caught_up while actionable work remains', () => { it('rejects caught_up while actionable work remains', () => {
const agenda = agendaWithWork(); const agenda = agendaWithWork();
const result = validateMemberWorkSyncReport({ const result = validateMemberWorkSyncReport({
@ -79,6 +140,26 @@ describe('validateMemberWorkSyncReport', () => {
expect(result).toMatchObject({ ok: false, code: 'blocked_without_evidence' }); expect(result).toMatchObject({ ok: false, code: 'blocked_without_evidence' });
}); });
it('accepts blocked reports when blocker evidence is referenced by display id', () => {
const agenda = leadAgendaWithBrokenDependency();
const result = validateMemberWorkSyncReport({
request: {
teamName: 'team-a',
memberName: 'team-lead',
state: 'blocked',
agendaFingerprint: agenda.fingerprint,
taskIds: ['22222222'],
},
agenda,
nowIso,
activeMemberNames: ['team-lead', 'bob'],
tokenValidation: validToken,
});
expect(result.ok).toBe(true);
expect(result.expiresAt).toBe('2026-04-29T00:30:00.000Z');
});
it('rejects stale fingerprints and foreign task ids', () => { it('rejects stale fingerprints and foreign task ids', () => {
const agenda = agendaWithWork(); const agenda = agendaWithWork();
const stale = validateMemberWorkSyncReport({ const stale = validateMemberWorkSyncReport({

View file

@ -210,10 +210,7 @@ class InMemoryOutboxStore implements MemberWorkSyncOutboxStorePort {
} }
} }
async countRecentDelivered(input: { async countRecentDelivered(input: { memberName: string; sinceIso: string }): Promise<number> {
memberName: string;
sinceIso: string;
}): Promise<number> {
return [...this.items.values()].filter( return [...this.items.values()].filter(
(item) => (item) =>
item.status === 'delivered' && item.status === 'delivered' &&
@ -300,7 +297,7 @@ function createDeps(options?: {
describe('MemberWorkSync use cases', () => { describe('MemberWorkSync use cases', () => {
it('reconciles actionable work into needs_sync without side effects', async () => { it('reconciles actionable work into needs_sync without side effects', async () => {
const { auditEvents, deps, store } = createDeps(); const { auditEvents, deps, store } = createDeps();
const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ const status = await new MemberWorkSyncReconciler(deps).execute({
teamName: 'team-a', teamName: 'team-a',
memberName: 'bob', memberName: 'bob',
}); });
@ -324,7 +321,7 @@ describe('MemberWorkSync use cases', () => {
it('accepts still_working as a bounded lease for the current fingerprint', async () => { it('accepts still_working as a bounded lease for the current fingerprint', async () => {
const { auditEvents, clock, deps } = createDeps(); const { auditEvents, clock, deps } = createDeps();
const reader = new MemberWorkSyncDiagnosticsReader(deps); const reader = new MemberWorkSyncReconciler(deps);
const reporter = new MemberWorkSyncReporter(deps); const reporter = new MemberWorkSyncReporter(deps);
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
@ -357,7 +354,7 @@ describe('MemberWorkSync use cases', () => {
it('uses app clock instead of model supplied reportedAt for lease timing', async () => { it('uses app clock instead of model supplied reportedAt for lease timing', async () => {
const { deps } = createDeps(); const { deps } = createDeps();
const reader = new MemberWorkSyncDiagnosticsReader(deps); const reader = new MemberWorkSyncReconciler(deps);
const reporter = new MemberWorkSyncReporter(deps); const reporter = new MemberWorkSyncReporter(deps);
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
@ -409,7 +406,7 @@ describe('MemberWorkSync use cases', () => {
it('accepts caught_up only when the app-side agenda is empty', async () => { it('accepts caught_up only when the app-side agenda is empty', async () => {
const { deps } = createDeps({ items: [] }); const { deps } = createDeps({ items: [] });
const reader = new MemberWorkSyncDiagnosticsReader(deps); const reader = new MemberWorkSyncReconciler(deps);
const reporter = new MemberWorkSyncReporter(deps); const reporter = new MemberWorkSyncReporter(deps);
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
@ -428,7 +425,7 @@ describe('MemberWorkSync use cases', () => {
it('marks status inactive when the team runtime is not active', async () => { it('marks status inactive when the team runtime is not active', async () => {
const { deps } = createDeps({ teamActive: false }); const { deps } = createDeps({ teamActive: false });
const status = await new MemberWorkSyncDiagnosticsReader(deps).execute({ const status = await new MemberWorkSyncReconciler(deps).execute({
teamName: 'team-a', teamName: 'team-a',
memberName: 'bob', memberName: 'bob',
}); });
@ -440,7 +437,7 @@ describe('MemberWorkSync use cases', () => {
it('records fingerprint transitions without treating them as progress proof', async () => { it('records fingerprint transitions without treating them as progress proof', async () => {
const { deps, source } = createDeps(); const { deps, source } = createDeps();
const reader = new MemberWorkSyncDiagnosticsReader(deps); const reader = new MemberWorkSyncReconciler(deps);
await reader.execute({ teamName: 'team-a', memberName: 'bob' }); await reader.execute({ teamName: 'team-a', memberName: 'bob' });
source.agenda.items = [ source.agenda.items = [
@ -487,6 +484,7 @@ describe('MemberWorkSync use cases', () => {
}); });
expect(outbox.ensures).toEqual([]); expect(outbox.ensures).toEqual([]);
expect(store.writes).toEqual([]);
}); });
it('creates one idempotent outbox nudge intent when Phase 2 readiness is green', async () => { it('creates one idempotent outbox nudge intent when Phase 2 readiness is green', async () => {
@ -517,6 +515,15 @@ describe('MemberWorkSync use cases', () => {
taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }], taskRefs: [{ teamName: 'team-a', taskId: 'task-1', displayId: '11111111' }],
}, },
}); });
const nudgeText = outbox.ensures[0]?.payload.text ?? '';
expect(nudgeText).toContain(
'member_work_sync_status with teamName "team-a" and memberName "bob"'
);
expect(nudgeText).toContain('member_work_sync_report with the same teamName/memberName');
expect(nudgeText).toContain('taskIds: "task-1"');
expect(nudgeText).toContain(
'Do not use provider names, runtime names, or team names as memberName'
);
}); });
it('dispatches due nudges only after revalidating current status and readiness', async () => { it('dispatches due nudges only after revalidating current status and readiness', async () => {
@ -544,12 +551,41 @@ describe('MemberWorkSync use cases', () => {
memberName: 'bob', memberName: 'bob',
messageId: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`, messageId: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`,
}); });
expect(outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`)).toMatchObject({ expect(
outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`)
).toMatchObject({
status: 'delivered', status: 'delivered',
deliveredMessageId: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`, deliveredMessageId: `member-work-sync:team-a:bob:${status.agenda.fingerprint}`,
}); });
}); });
it('recomputes agenda before dispatch and supersedes stale outbox fingerprints', async () => {
const outbox = new InMemoryOutboxStore();
const inbox = new InMemoryInboxNudge();
const { deps, source, store } = createDeps({ outboxStore: outbox, inboxNudge: inbox });
store.phase2ReadinessState = 'shadow_ready';
const status = await new MemberWorkSyncReconciler(deps).execute(
{ teamName: 'team-a', memberName: 'bob' },
{ reconciledBy: 'queue', triggerReasons: ['task_changed'] }
);
source.agenda.items = [];
const summary = await new MemberWorkSyncNudgeDispatcher(deps).dispatchDue({
teamNames: ['team-a'],
claimedBy: 'test-dispatcher',
});
expect(summary).toMatchObject({ claimed: 1, delivered: 0, superseded: 1 });
expect(inbox.inserted).toEqual([]);
expect(
outbox.items.get(`member-work-sync:team-a:bob:${status.agenda.fingerprint}`)
).toMatchObject({
status: 'superseded',
lastError: 'status_no_longer_matches_outbox',
});
});
it('does not dispatch stale outbox items after the member reports still working', async () => { it('does not dispatch stale outbox items after the member reports still working', async () => {
const outbox = new InMemoryOutboxStore(); const outbox = new InMemoryOutboxStore();
const inbox = new InMemoryInboxNudge(); const inbox = new InMemoryInboxNudge();
@ -579,7 +615,9 @@ describe('MemberWorkSync use cases', () => {
expect(summary).toMatchObject({ claimed: 1, delivered: 0, superseded: 1 }); expect(summary).toMatchObject({ claimed: 1, delivered: 0, superseded: 1 });
expect(inbox.inserted).toEqual([]); expect(inbox.inserted).toEqual([]);
expect(outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)).toMatchObject({ expect(
outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)
).toMatchObject({
status: 'superseded', status: 'superseded',
lastError: 'status_no_longer_matches_outbox', lastError: 'status_no_longer_matches_outbox',
}); });
@ -630,7 +668,9 @@ describe('MemberWorkSync use cases', () => {
expect(summary).toMatchObject({ claimed: 1, delivered: 0, retryable: 1 }); expect(summary).toMatchObject({ claimed: 1, delivered: 0, retryable: 1 });
expect(inbox.inserted).toEqual([]); expect(inbox.inserted).toEqual([]);
expect(outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)).toMatchObject({ expect(
outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)
).toMatchObject({
status: 'failed_retryable', status: 'failed_retryable',
lastError: 'member_nudge_rate_limited', lastError: 'member_nudge_rate_limited',
nextAttemptAt: '2026-04-29T01:00:00.000Z', nextAttemptAt: '2026-04-29T01:00:00.000Z',
@ -667,7 +707,9 @@ describe('MemberWorkSync use cases', () => {
expect(summary).toMatchObject({ claimed: 1, delivered: 0, retryable: 1 }); expect(summary).toMatchObject({ claimed: 1, delivered: 0, retryable: 1 });
expect(inbox.inserted).toEqual([]); expect(inbox.inserted).toEqual([]);
expect(outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)).toMatchObject({ expect(
outbox.items.get(`member-work-sync:team-a:bob:${current.agenda.fingerprint}`)
).toMatchObject({
status: 'failed_retryable', status: 'failed_retryable',
lastError: 'member_busy:active_tool_activity', lastError: 'member_busy:active_tool_activity',
nextAttemptAt: '2026-04-29T00:02:00.000Z', nextAttemptAt: '2026-04-29T00:02:00.000Z',
@ -717,7 +759,7 @@ describe('MemberWorkSync use cases', () => {
it('rejects invalid report tokens without recording replayable intents', async () => { it('rejects invalid report tokens without recording replayable intents', async () => {
const { deps, store } = createDeps(); const { deps, store } = createDeps();
const reader = new MemberWorkSyncDiagnosticsReader(deps); const reader = new MemberWorkSyncReconciler(deps);
const reporter = new MemberWorkSyncReporter(deps); const reporter = new MemberWorkSyncReporter(deps);
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
@ -741,7 +783,7 @@ describe('MemberWorkSync use cases', () => {
it('replays pending controller intents through the same app validator', async () => { it('replays pending controller intents through the same app validator', async () => {
const { deps, store } = createDeps(); const { deps, store } = createDeps();
const reader = new MemberWorkSyncDiagnosticsReader(deps); const reader = new MemberWorkSyncReconciler(deps);
const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' }); const current = await reader.execute({ teamName: 'team-a', memberName: 'bob' });
store.pendingIntents.set('intent-1', { store.pendingIntents.set('intent-1', {
id: 'intent-1', id: 'intent-1',

View file

@ -1,4 +1,4 @@
import { mkdtemp, rm } from 'node:fs/promises'; import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
@ -9,11 +9,13 @@ import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infras
describe('HmacMemberWorkSyncReportTokenAdapter', () => { describe('HmacMemberWorkSyncReportTokenAdapter', () => {
let root: string; let root: string;
let paths: MemberWorkSyncStorePaths;
let adapter: HmacMemberWorkSyncReportTokenAdapter; let adapter: HmacMemberWorkSyncReportTokenAdapter;
beforeEach(async () => { beforeEach(async () => {
root = await mkdtemp(join(tmpdir(), 'member-work-sync-token-')); root = await mkdtemp(join(tmpdir(), 'member-work-sync-token-'));
adapter = new HmacMemberWorkSyncReportTokenAdapter(new MemberWorkSyncStorePaths(root)); paths = new MemberWorkSyncStorePaths(root);
adapter = new HmacMemberWorkSyncReportTokenAdapter(paths);
}); });
afterEach(async () => { afterEach(async () => {
@ -76,4 +78,55 @@ describe('HmacMemberWorkSyncReportTokenAdapter', () => {
}) })
).resolves.toEqual({ ok: false, reason: 'expired' }); ).resolves.toEqual({ ok: false, reason: 'expired' });
}); });
it('recovers from a corrupt token secret file', async () => {
await mkdir(paths.getTeamDir('team-a'), { recursive: true });
await writeFile(paths.getReportTokenSecretPath('team-a'), '{broken', 'utf8');
const issued = await adapter.create({
teamName: 'team-a',
memberName: 'bob',
agendaFingerprint: 'agenda:v1:abc',
issuedAt: '2026-04-29T00:00:00.000Z',
});
const secretFile = JSON.parse(await readFile(paths.getReportTokenSecretPath('team-a'), 'utf8'));
expect(secretFile.schemaVersion).toBe(1);
expect(typeof secretFile.secret).toBe('string');
await expect(
adapter.verify({
token: issued.token,
teamName: 'team-a',
memberName: 'bob',
agendaFingerprint: 'agenda:v1:abc',
nowIso: '2026-04-29T00:01:00.000Z',
})
).resolves.toEqual({ ok: true });
});
it('does not cache a failed token secret load forever', async () => {
const secretPath = paths.getReportTokenSecretPath('team-a');
await mkdir(secretPath, { recursive: true });
await expect(
adapter.create({
teamName: 'team-a',
memberName: 'bob',
agendaFingerprint: 'agenda:v1:abc',
issuedAt: '2026-04-29T00:00:00.000Z',
})
).rejects.toBeTruthy();
await rm(secretPath, { recursive: true, force: true });
await expect(
adapter.create({
teamName: 'team-a',
memberName: 'bob',
agendaFingerprint: 'agenda:v1:abc',
issuedAt: '2026-04-29T00:00:00.000Z',
})
).resolves.toMatchObject({
expiresAt: '2026-04-29T00:15:00.000Z',
});
});
}); });

View file

@ -45,6 +45,108 @@ describe('MemberWorkSyncEventQueue', () => {
await queue.stop(); await queue.stop();
}); });
it('bounds coalescing so noisy event streams cannot starve reconcile forever', async () => {
const reconciles: unknown[] = [];
const queue = new MemberWorkSyncEventQueue({
quietWindowMs: 100,
triggerTiming: {
task_changed: { runAfterMs: 100, maxCoalesceWaitMs: 250 },
},
reconcile: async (request, context) => {
reconciles.push({ request, context });
},
isTeamActive: () => true,
});
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
await vi.advanceTimersByTimeAsync(90);
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
await vi.advanceTimersByTimeAsync(90);
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
await vi.advanceTimersByTimeAsync(69);
expect(reconciles).toHaveLength(0);
expect(queue.getDiagnostics()).toMatchObject({
queued: 1,
queuedItems: [
{
memberName: 'bob',
triggerReasonCounts: { task_changed: 3 },
},
],
});
await vi.advanceTimersByTimeAsync(1);
expect(reconciles).toHaveLength(1);
await queue.stop();
});
it('lets manual refresh expedite an already queued delayed reconcile', async () => {
const reconciles: unknown[] = [];
const queue = new MemberWorkSyncEventQueue({
triggerTiming: {
task_changed: { runAfterMs: 1_000, maxCoalesceWaitMs: 5_000 },
manual_refresh: { runAfterMs: 0, maxCoalesceWaitMs: 0 },
},
reconcile: async (request, context) => {
reconciles.push({ request, context });
},
isTeamActive: () => true,
});
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
await vi.advanceTimersByTimeAsync(100);
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' });
await vi.advanceTimersByTimeAsync(1);
expect(reconciles).toHaveLength(1);
expect(reconciles[0]).toMatchObject({
context: { triggerReasons: ['manual_refresh', 'task_changed'] },
});
await queue.stop();
});
it('does not let legacy quiet window override delay manual refresh', async () => {
const reconciles: unknown[] = [];
const queue = new MemberWorkSyncEventQueue({
quietWindowMs: 10_000,
reconcile: async (request, context) => {
reconciles.push({ request, context });
},
isTeamActive: () => true,
});
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' });
await vi.advanceTimersByTimeAsync(1);
expect(reconciles).toHaveLength(1);
await queue.stop();
});
it('does not let a later quiet-window event delay a queued manual refresh', async () => {
const reconciles: unknown[] = [];
const queue = new MemberWorkSyncEventQueue({
triggerTiming: {
task_changed: { runAfterMs: 1_000, maxCoalesceWaitMs: 5_000 },
},
reconcile: async (request, context) => {
reconciles.push({ request, context });
},
isTeamActive: () => true,
});
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' });
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
await vi.advanceTimersByTimeAsync(1);
expect(reconciles).toHaveLength(1);
expect(reconciles[0]).toMatchObject({
context: { triggerReasons: ['manual_refresh', 'task_changed'] },
});
await queue.stop();
});
it('drops queued work for inactive teams without reconciling', async () => { it('drops queued work for inactive teams without reconciling', async () => {
const reconcile = vi.fn(); const reconcile = vi.fn();
const queue = new MemberWorkSyncEventQueue({ const queue = new MemberWorkSyncEventQueue({
@ -93,6 +195,80 @@ describe('MemberWorkSyncEventQueue', () => {
await queue.stop(); await queue.stop();
}); });
it('lets manual refresh request an immediate follow-up after an active reconcile', async () => {
let release: () => void = () => {
throw new Error('reconcile did not start');
};
const reconciles: unknown[] = [];
const queue = new MemberWorkSyncEventQueue({
reconcile: async (request, context) => {
reconciles.push({ request, context });
if (reconciles.length === 1) {
await new Promise<void>((resolve) => {
release = resolve;
});
}
},
isTeamActive: () => true,
});
queue.enqueue({
teamName: 'team-a',
memberName: 'bob',
triggerReason: 'config_changed',
runAfterMs: 0,
});
await vi.advanceTimersByTimeAsync(0);
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'manual_refresh' });
release();
await vi.advanceTimersByTimeAsync(1);
expect(reconciles).toHaveLength(2);
expect(reconciles[1]).toMatchObject({
context: { triggerReasons: ['config_changed', 'manual_refresh'] },
});
await queue.stop();
});
it('does not let a later event delay a due item waiting behind concurrency', async () => {
let release: () => void = () => {
throw new Error('reconcile did not start');
};
const reconciles: unknown[] = [];
const queue = new MemberWorkSyncEventQueue({
concurrency: 1,
triggerTiming: {
task_changed: { runAfterMs: 0, maxCoalesceWaitMs: 5_000 },
inbox_changed: { runAfterMs: 1_000, maxCoalesceWaitMs: 5_000 },
},
reconcile: async (request, context) => {
reconciles.push({ request, context });
if (reconciles.length === 1) {
await new Promise<void>((resolve) => {
release = resolve;
});
}
},
isTeamActive: () => true,
});
queue.enqueue({ teamName: 'team-a', memberName: 'alice', triggerReason: 'task_changed' });
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'task_changed' });
await vi.advanceTimersByTimeAsync(0);
queue.enqueue({ teamName: 'team-a', memberName: 'bob', triggerReason: 'inbox_changed' });
release();
await vi.advanceTimersByTimeAsync(1);
expect(reconciles).toHaveLength(2);
expect(reconciles[1]).toMatchObject({
request: { memberName: 'bob' },
context: { triggerReasons: ['inbox_changed', 'task_changed'] },
});
await queue.stop();
});
it('does not spin timers while concurrency is saturated', async () => { it('does not spin timers while concurrency is saturated', async () => {
let release: () => void = () => { let release: () => void = () => {
throw new Error('reconcile did not start'); throw new Error('reconcile did not start');

View file

@ -0,0 +1,132 @@
import { describe, expect, it, vi } from 'vitest';
import { MemberWorkSyncTaskImpactResolver } from '@features/member-work-sync/main/adapters/input/MemberWorkSyncTaskImpactResolver';
import type { TeamTask } from '@shared/types';
describe('MemberWorkSyncTaskImpactResolver', () => {
it('targets owner, reviewer, dependent owners and lead oversight without team-wide fan-out', async () => {
const tasks: TeamTask[] = [
{
id: 'task-a',
displayId: '#11111111',
subject: 'Changed',
status: 'in_progress',
owner: 'alice',
reviewState: 'review',
historyEvents: [
{
id: 'evt-review',
type: 'review_requested',
timestamp: '2026-04-29T00:00:00.000Z',
from: 'none',
to: 'review',
reviewer: 'bob',
},
],
},
{
id: 'task-b',
subject: 'Unblocked by A',
status: 'pending',
owner: 'tom',
blockedBy: ['task-a'],
},
];
const resolver = new MemberWorkSyncTaskImpactResolver({
taskReader: { getTasks: vi.fn(async () => tasks) },
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
activeMemberSource: {
loadActiveMemberNames: vi.fn(async () => ['alice', 'bob', 'team-lead', 'tom']),
},
} as never);
await expect(resolver.resolve({ teamName: 'team-a', taskId: '#11111111' })).resolves.toEqual({
memberNames: ['alice', 'bob', 'tom'],
fallbackTeamWide: false,
diagnostics: [],
});
});
it('falls back to team-wide routing when a task was removed before impact can be resolved', async () => {
const resolver = new MemberWorkSyncTaskImpactResolver({
taskReader: { getTasks: vi.fn(async () => []) },
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
activeMemberSource: { loadActiveMemberNames: vi.fn(async () => ['alice']) },
} as never);
await expect(resolver.resolve({ teamName: 'team-a', taskId: 'deleted-task' })).resolves.toEqual(
{
memberNames: [],
fallbackTeamWide: true,
diagnostics: ['task_not_found'],
}
);
});
it('targets lead when a deleted task breaks active dependent work', async () => {
const tasks: TeamTask[] = [
{
id: 'task-deleted',
subject: 'Deleted dependency',
status: 'deleted',
owner: 'alice',
deletedAt: '2026-04-29T00:00:00.000Z',
},
{
id: 'task-dependent',
subject: 'Depends on deleted task',
status: 'pending',
owner: 'tom',
blockedBy: ['task-deleted'],
},
];
const resolver = new MemberWorkSyncTaskImpactResolver({
taskReader: { getTasks: vi.fn(async () => tasks) },
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
activeMemberSource: {
loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom']),
},
} as never);
await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-deleted' })).resolves.toEqual(
{
memberNames: ['alice', 'team-lead', 'tom'],
fallbackTeamWide: false,
diagnostics: ['dependent_task_has_deleted_dependency'],
}
);
});
it('targets dependent owners when dependencies reference the changed task by display id', async () => {
const tasks: TeamTask[] = [
{
id: 'task-a',
displayId: '#11111111',
subject: 'Changed dependency',
status: 'in_progress',
owner: 'alice',
},
{
id: 'task-b',
subject: 'Depends on display id',
status: 'pending',
owner: 'tom',
blockedBy: ['11111111'],
},
];
const resolver = new MemberWorkSyncTaskImpactResolver({
taskReader: { getTasks: vi.fn(async () => tasks) },
kanbanManager: { getState: vi.fn(async () => ({ tasks: {} })) },
activeMemberSource: {
loadActiveMemberNames: vi.fn(async () => ['alice', 'team-lead', 'tom']),
},
} as never);
await expect(resolver.resolve({ teamName: 'team-a', taskId: 'task-a' })).resolves.toEqual({
memberNames: ['alice', 'tom'],
fallbackTeamWide: false,
diagnostics: [],
});
});
});

View file

@ -37,6 +37,43 @@ describe('MemberWorkSyncTeamChangeRouter', () => {
}); });
}); });
it('routes task events to resolver-impacted members when task identity is available', async () => {
const queue = {
enqueue: vi.fn(),
dropTeam: vi.fn(),
};
const resolver = {
resolve: vi.fn(async () => ({
memberNames: ['bob'],
fallbackTeamWide: false,
diagnostics: [],
})),
};
const router = new MemberWorkSyncTeamChangeRouter(
{ loadActiveMemberNames: async () => ['alice', 'bob'] },
queue as never,
undefined,
resolver as never
);
router.noteTeamChange({
type: 'task',
teamName: 'team-a',
detail: 'task-1.json',
taskId: 'task-1',
});
await Promise.resolve();
await Promise.resolve();
expect(resolver.resolve).toHaveBeenCalledWith({ teamName: 'team-a', taskId: 'task-1' });
expect(queue.enqueue).toHaveBeenCalledTimes(1);
expect(queue.enqueue).toHaveBeenCalledWith({
teamName: 'team-a',
memberName: 'bob',
triggerReason: 'task_changed',
});
});
it('routes inbox and tool-finish events to the addressed member only', () => { it('routes inbox and tool-finish events to the addressed member only', () => {
const { queue, router } = createRouter(); const { queue, router } = createRouter();

View file

@ -4,12 +4,15 @@ import path from 'path';
import { afterEach, describe, expect, it, vi } from 'vitest'; import { afterEach, describe, expect, it, vi } from 'vitest';
import { import {
MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV,
buildMemberWorkSyncRuntimeTurnSettledEnvironment, buildMemberWorkSyncRuntimeTurnSettledEnvironment,
createMemberWorkSyncFeature, createMemberWorkSyncFeature,
resolveMemberWorkSyncNudgeSideEffectsEnabled,
} from '@features/member-work-sync/main'; } from '@features/member-work-sync/main';
import { buildMemberWorkSyncOutboxEnsureInput } from '@features/member-work-sync/core/domain';
import { JsonMemberWorkSyncStore } from '@features/member-work-sync/main/infrastructure/JsonMemberWorkSyncStore';
import { MemberWorkSyncStorePaths } from '@features/member-work-sync/main/infrastructure/MemberWorkSyncStorePaths';
import { NodeHashAdapter } from '@features/member-work-sync/main/infrastructure/NodeHashAdapter';
import { RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV } from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment'; import { RUNTIME_TURN_SETTLED_SPOOL_ROOT_ENV } from '@features/member-work-sync/main/infrastructure/runtimeTurnSettledEnvironment';
import { getTeamsBasePath, setClaudeBasePathOverride } from '@main/utils/pathDecoder';
const tempRoots: string[] = []; const tempRoots: string[] = [];
@ -20,61 +23,208 @@ function makeTempRoot(): string {
} }
afterEach(() => { afterEach(() => {
setClaudeBasePathOverride(null);
for (const root of tempRoots.splice(0)) { for (const root of tempRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true }); fs.rmSync(root, { recursive: true, force: true });
} }
}); });
async function seedShadowReadyMetrics(input: {
teamsBasePath: string;
teamName: string;
memberName: string;
}): Promise<void> {
const metricsPath = path.join(
input.teamsBasePath,
input.teamName,
'.member-work-sync',
'indexes',
'metrics.json'
);
await fs.promises.mkdir(path.dirname(metricsPath), { recursive: true });
await fs.promises.writeFile(
metricsPath,
`${JSON.stringify(
{
schemaVersion: 2,
members: {
[input.memberName]: {
memberName: input.memberName,
state: 'caught_up',
agendaFingerprint: 'agenda:v1:seed',
actionableCount: 0,
evaluatedAt: '2026-01-01T00:00:00.000Z',
},
},
recentEvents: Array.from({ length: 20 }, (_, index) => ({
id: `seed-status-${index}`,
teamName: input.teamName,
memberName: input.memberName,
kind: 'status_evaluated',
state: 'caught_up',
agendaFingerprint: `agenda:v1:seed-${index}`,
recordedAt: new Date(Date.UTC(2026, 0, 1, index)).toISOString(),
actionableCount: 0,
})),
},
null,
2
)}\n`,
'utf8'
);
}
async function waitForAssertion(assertion: () => Promise<void> | void): Promise<void> {
const deadline = Date.now() + 1_000;
let lastError: unknown;
while (Date.now() < deadline) {
try {
await assertion();
return;
} catch (error) {
lastError = error;
await new Promise((resolve) => setTimeout(resolve, 10));
}
}
if (lastError) {
throw lastError;
}
await assertion();
}
describe('createMemberWorkSyncFeature composition', () => { describe('createMemberWorkSyncFeature composition', () => {
it('keeps nudge side effects opt-in even when shadow readiness becomes green', () => { it('dispatches a due nudge through the real outbox and inbox by default', async () => {
expect(resolveMemberWorkSyncNudgeSideEffectsEnabled({})).toBe(false); const claudeRoot = makeTempRoot();
expect( setClaudeBasePathOverride(claudeRoot);
resolveMemberWorkSyncNudgeSideEffectsEnabled({ const teamsBasePath = getTeamsBasePath();
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: 'maybe', const teamName = 'team-a';
}) const memberName = 'bob';
).toBe(false);
});
it.each(['1', 'true', 'yes', 'on'])(
'enables nudge side effects only for explicit truthy env value %s',
(value) => {
expect(
resolveMemberWorkSyncNudgeSideEffectsEnabled({
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: value,
})
).toBe(true);
}
);
it.each(['0', 'false', 'no', 'off', ''])(
'keeps nudge side effects disabled for explicit falsy env value %s',
(value) => {
expect(
resolveMemberWorkSyncNudgeSideEffectsEnabled({
[MEMBER_WORK_SYNC_NUDGE_SIDE_EFFECTS_ENV]: value,
})
).toBe(false);
}
);
it('returns an empty dispatch summary when nudge side effects are disabled', async () => {
const feature = createMemberWorkSyncFeature({ const feature = createMemberWorkSyncFeature({
teamsBasePath: makeTempRoot(), teamsBasePath,
configReader: {} as never, configReader: {
taskReader: {} as never, getConfig: vi.fn(async () => ({
kanbanManager: {} as never, name: teamName,
membersMetaStore: {} as never, members: [{ name: memberName }],
nudgeSideEffectsEnabled: false, })),
} as never,
taskReader: {
getTasks: vi.fn(async () => [
{
id: 'task-1',
displayId: '11111111',
subject: 'Ship sync',
status: 'pending',
owner: memberName,
},
]),
} as never,
kanbanManager: {
getState: vi.fn(async () => ({
teamName,
reviewers: [],
tasks: {},
})),
} as never,
membersMetaStore: {
getMembers: vi.fn(async () => []),
} as never,
}); });
try { try {
await expect(feature.dispatchDueNudges(['team-a'])).resolves.toEqual({ await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
claimed: 0, const status = await feature.refreshStatus({ teamName, memberName });
delivered: 0, expect(status).toMatchObject({
state: 'needs_sync',
shadow: { wouldNudge: true },
});
await expect(feature.getMetrics({ teamName })).resolves.toMatchObject({
phase2Readiness: { state: 'shadow_ready' },
});
const outboxInput = buildMemberWorkSyncOutboxEnsureInput({
status,
hash: new NodeHashAdapter(),
nowIso: status.evaluatedAt,
});
expect(outboxInput).not.toBeNull();
const store = new JsonMemberWorkSyncStore(new MemberWorkSyncStorePaths(teamsBasePath));
await expect(store.ensurePending(outboxInput!)).resolves.toMatchObject({
ok: true,
outcome: 'created',
});
await expect(feature.dispatchDueNudges([teamName])).resolves.toEqual({
claimed: 1,
delivered: 1,
superseded: 0, superseded: 0,
retryable: 0, retryable: 0,
terminal: 0, terminal: 0,
}); });
await expect(
fs.promises.readFile(path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`), {
encoding: 'utf8',
})
).resolves.toContain(outboxInput!.id);
} finally {
await feature.dispose();
}
});
it('plans and dispatches due nudges after queued reconcile by default', async () => {
const claudeRoot = makeTempRoot();
setClaudeBasePathOverride(claudeRoot);
const teamsBasePath = getTeamsBasePath();
const teamName = 'team-a';
const memberName = 'bob';
const feature = createMemberWorkSyncFeature({
teamsBasePath,
configReader: {
getConfig: vi.fn(async () => ({
name: teamName,
members: [{ name: memberName }],
})),
} as never,
taskReader: {
getTasks: vi.fn(async () => [
{
id: 'task-1',
displayId: '11111111',
subject: 'Ship sync',
status: 'pending',
owner: memberName,
},
]),
} as never,
kanbanManager: {
getState: vi.fn(async () => ({
teamName,
reviewers: [],
tasks: {},
})),
} as never,
membersMetaStore: {
getMembers: vi.fn(async () => []),
} as never,
queueQuietWindowMs: 1,
});
try {
await seedShadowReadyMetrics({ teamsBasePath, teamName, memberName });
feature.noteTeamChange({
type: 'task',
teamName,
taskId: 'task-1',
} as never);
await waitForAssertion(async () => {
expect(feature.getQueueDiagnostics()).toMatchObject({ reconciled: 1 });
const inbox = await fs.promises.readFile(
path.join(teamsBasePath, teamName, 'inboxes', `${memberName}.json`),
'utf8'
);
expect(inbox).toContain('member_work_sync_nudge');
expect(inbox).toContain(`member-work-sync:${teamName}:${memberName}:agenda:v1:`);
});
} finally { } finally {
await feature.dispose(); await feature.dispose();
} }
@ -96,7 +246,6 @@ describe('createMemberWorkSyncFeature composition', () => {
membersMetaStore: { membersMetaStore: {
getMembers: vi.fn(async () => []), getMembers: vi.fn(async () => []),
} as never, } as never,
nudgeSideEffectsEnabled: false,
}); });
try { try {
@ -108,7 +257,7 @@ describe('createMemberWorkSyncFeature composition', () => {
} }
}); });
it('builds Claude Stop hook settings without requiring nudge side effects', async () => { it('builds Claude Stop hook settings with nudges active by default', async () => {
const root = makeTempRoot(); const root = makeTempRoot();
const feature = createMemberWorkSyncFeature({ const feature = createMemberWorkSyncFeature({
teamsBasePath: root, teamsBasePath: root,
@ -116,7 +265,6 @@ describe('createMemberWorkSyncFeature composition', () => {
taskReader: {} as never, taskReader: {} as never,
kanbanManager: {} as never, kanbanManager: {} as never,
membersMetaStore: {} as never, membersMetaStore: {} as never,
nudgeSideEffectsEnabled: false,
}); });
try { try {
@ -145,7 +293,7 @@ describe('createMemberWorkSyncFeature composition', () => {
} }
}); });
it('builds Codex turn-settled environment without requiring nudge side effects', async () => { it('builds Codex turn-settled environment with nudges active by default', async () => {
const root = makeTempRoot(); const root = makeTempRoot();
const feature = createMemberWorkSyncFeature({ const feature = createMemberWorkSyncFeature({
teamsBasePath: root, teamsBasePath: root,
@ -153,7 +301,6 @@ describe('createMemberWorkSyncFeature composition', () => {
taskReader: {} as never, taskReader: {} as never,
kanbanManager: {} as never, kanbanManager: {} as never,
membersMetaStore: {} as never, membersMetaStore: {} as never,
nudgeSideEffectsEnabled: false,
}); });
try { try {
@ -172,7 +319,7 @@ describe('createMemberWorkSyncFeature composition', () => {
} }
}); });
it('builds OpenCode turn-settled environment without requiring nudge side effects', async () => { it('builds OpenCode turn-settled environment with nudges active by default', async () => {
const root = makeTempRoot(); const root = makeTempRoot();
const feature = createMemberWorkSyncFeature({ const feature = createMemberWorkSyncFeature({
teamsBasePath: root, teamsBasePath: root,
@ -180,7 +327,6 @@ describe('createMemberWorkSyncFeature composition', () => {
taskReader: {} as never, taskReader: {} as never,
kanbanManager: {} as never, kanbanManager: {} as never,
membersMetaStore: {} as never, membersMetaStore: {} as never,
nudgeSideEffectsEnabled: false,
}); });
try { try {

View file

@ -3,9 +3,13 @@ import { describe, expect, it, vi } from 'vitest';
import { import {
MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_METRICS,
MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_GET_STATUS,
MEMBER_WORK_SYNC_REFRESH_STATUS,
MEMBER_WORK_SYNC_REPORT, MEMBER_WORK_SYNC_REPORT,
} from '@features/member-work-sync/contracts'; } from '@features/member-work-sync/contracts';
import { registerMemberWorkSyncIpc, removeMemberWorkSyncIpc } from '@features/member-work-sync/main'; import {
registerMemberWorkSyncIpc,
removeMemberWorkSyncIpc,
} from '@features/member-work-sync/main';
import type { import type {
MemberWorkSyncMetricsRequest, MemberWorkSyncMetricsRequest,
@ -57,6 +61,21 @@ function makeFeature(): MemberWorkSyncFeatureFacade {
evaluatedAt: '2026-04-29T00:00:00.000Z', evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: [], diagnostics: [],
})), })),
refreshStatus: vi.fn(async (request) => ({
teamName: request.teamName,
memberName: request.memberName,
state: 'needs_sync' as const,
agenda: {
teamName: request.teamName,
memberName: request.memberName,
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:test',
items: [],
diagnostics: [],
},
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: [],
})),
getMetrics: vi.fn(async (request) => ({ getMetrics: vi.fn(async (request) => ({
teamName: request.teamName, teamName: request.teamName,
generatedAt: '2026-04-29T00:00:00.000Z', generatedAt: '2026-04-29T00:00:00.000Z',
@ -135,9 +154,14 @@ describe('registerMemberWorkSyncIpc', () => {
registerMemberWorkSyncIpc(ipcMain, feature); registerMemberWorkSyncIpc(ipcMain, feature);
expect(ipcMain.handle).toHaveBeenCalledTimes(3); expect(ipcMain.handle).toHaveBeenCalledTimes(4);
expect([...handlers.keys()].sort()).toEqual( expect([...handlers.keys()].sort()).toEqual(
[MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_REPORT].sort() [
MEMBER_WORK_SYNC_GET_METRICS,
MEMBER_WORK_SYNC_GET_STATUS,
MEMBER_WORK_SYNC_REFRESH_STATUS,
MEMBER_WORK_SYNC_REPORT,
].sort()
); );
const statusRequest: MemberWorkSyncStatusRequest = { teamName: 'team-a', memberName: 'bob' }; const statusRequest: MemberWorkSyncStatusRequest = { teamName: 'team-a', memberName: 'bob' };
@ -152,6 +176,9 @@ describe('registerMemberWorkSyncIpc', () => {
await expect( await expect(
handlers.get(MEMBER_WORK_SYNC_GET_STATUS)?.({}, statusRequest) handlers.get(MEMBER_WORK_SYNC_GET_STATUS)?.({}, statusRequest)
).resolves.toMatchObject({ teamName: 'team-a', memberName: 'bob' }); ).resolves.toMatchObject({ teamName: 'team-a', memberName: 'bob' });
await expect(
handlers.get(MEMBER_WORK_SYNC_REFRESH_STATUS)?.({}, statusRequest)
).resolves.toMatchObject({ teamName: 'team-a', memberName: 'bob', state: 'needs_sync' });
await expect( await expect(
handlers.get(MEMBER_WORK_SYNC_GET_METRICS)?.({}, metricsRequest) handlers.get(MEMBER_WORK_SYNC_GET_METRICS)?.({}, metricsRequest)
).resolves.toMatchObject({ teamName: 'team-a' }); ).resolves.toMatchObject({ teamName: 'team-a' });
@ -160,6 +187,7 @@ describe('registerMemberWorkSyncIpc', () => {
); );
expect(feature.getStatus).toHaveBeenCalledWith(statusRequest); expect(feature.getStatus).toHaveBeenCalledWith(statusRequest);
expect(feature.refreshStatus).toHaveBeenCalledWith(statusRequest);
expect(feature.getMetrics).toHaveBeenCalledWith(metricsRequest); expect(feature.getMetrics).toHaveBeenCalledWith(metricsRequest);
expect(feature.report).toHaveBeenCalledWith(reportRequest); expect(feature.report).toHaveBeenCalledWith(reportRequest);
}); });
@ -209,8 +237,9 @@ describe('registerMemberWorkSyncIpc', () => {
removeMemberWorkSyncIpc(ipcMain); removeMemberWorkSyncIpc(ipcMain);
expect(ipcMain.removeHandler).toHaveBeenCalledTimes(3); expect(ipcMain.removeHandler).toHaveBeenCalledTimes(4);
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_STATUS); expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_STATUS);
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REFRESH_STATUS);
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS); expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS);
expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REPORT); expect(ipcMain.removeHandler).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REPORT);
expect([...handlers.keys()]).toEqual(['unrelated:channel']); expect([...handlers.keys()]).toEqual(['unrelated:channel']);

View file

@ -3,6 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
import { import {
MEMBER_WORK_SYNC_GET_METRICS, MEMBER_WORK_SYNC_GET_METRICS,
MEMBER_WORK_SYNC_GET_STATUS, MEMBER_WORK_SYNC_GET_STATUS,
MEMBER_WORK_SYNC_REFRESH_STATUS,
MEMBER_WORK_SYNC_REPORT, MEMBER_WORK_SYNC_REPORT,
} from '@features/member-work-sync/contracts'; } from '@features/member-work-sync/contracts';
import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload'; import { createMemberWorkSyncBridge } from '@features/member-work-sync/preload';
@ -41,6 +42,19 @@ describe('createMemberWorkSyncBridge', () => {
expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS, request); expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_GET_METRICS, request);
}); });
it('invokes the refresh status channel without changing the request payload', async () => {
const request: MemberWorkSyncStatusRequest = { teamName: 'team-a', memberName: 'bob' };
const response = { ok: true };
const ipcRenderer = {
invoke: vi.fn(async () => response),
} as unknown as IpcRenderer;
const bridge = createMemberWorkSyncBridge(ipcRenderer);
await expect(bridge.refreshStatus(request)).resolves.toBe(response);
expect(ipcRenderer.invoke).toHaveBeenCalledWith(MEMBER_WORK_SYNC_REFRESH_STATUS, request);
});
it('invokes the report channel without changing the request payload', async () => { it('invokes the report channel without changing the request payload', async () => {
const request: MemberWorkSyncReportRequest = { const request: MemberWorkSyncReportRequest = {
teamName: 'team-a', teamName: 'team-a',
@ -71,8 +85,6 @@ describe('createMemberWorkSyncBridge', () => {
} as unknown as IpcRenderer; } as unknown as IpcRenderer;
const bridge = createMemberWorkSyncBridge(ipcRenderer); const bridge = createMemberWorkSyncBridge(ipcRenderer);
await expect(bridge.getStatus({ teamName: 'team-a', memberName: 'bob' })).rejects.toBe( await expect(bridge.getStatus({ teamName: 'team-a', memberName: 'bob' })).rejects.toBe(failure);
failure
);
}); });
}); });

View file

@ -453,4 +453,147 @@ describe('HTTP team runtime routes', () => {
await app.close(); await app.close();
} }
}); });
it('serves member work sync diagnostics and explicit refresh routes', async () => {
const app = Fastify();
const mocks = createServicesMock();
const queueDiagnostics = {
queued: 0,
running: 0,
enqueued: 2,
coalesced: 1,
reconciled: 1,
dropped: 0,
failed: 0,
queuedItems: [],
runningItems: [],
};
const metrics = {
teamName: 'demo-team',
generatedAt: '2026-05-05T00:00:00.000Z',
memberCount: 1,
stateCounts: {
caught_up: 1,
needs_sync: 0,
still_working: 0,
blocked: 0,
inactive: 0,
unknown: 0,
},
actionableItemCount: 0,
wouldNudgeCount: 0,
fingerprintChangeCount: 0,
reportAcceptedCount: 0,
reportRejectedCount: 0,
recentEvents: [],
phase2Readiness: {
state: 'collecting_shadow_data',
reasons: ['insufficient_members'],
thresholds: {
minObservedMembers: 2,
minStatusEvents: 10,
minObservationHours: 1,
maxWouldNudgesPerMemberHour: 1,
maxFingerprintChangesPerMemberHour: 1,
maxReportRejectionRate: 0.1,
},
rates: {
observationHours: 0,
statusEventCount: 0,
wouldNudgesPerMemberHour: 0,
fingerprintChangesPerMemberHour: 0,
reportRejectionRate: 0,
},
diagnostics: [],
},
};
const refreshedStatus = {
teamName: 'demo-team',
memberName: 'bob',
state: 'caught_up',
agenda: {
teamName: 'demo-team',
memberName: 'bob',
generatedAt: '2026-05-05T00:00:00.000Z',
fingerprint: 'empty',
items: [],
diagnostics: [],
},
evaluatedAt: '2026-05-05T00:00:00.000Z',
diagnostics: [],
};
const memberWorkSyncFeature = {
getStatus: vi.fn(),
refreshStatus: vi.fn(async () => refreshedStatus),
getMetrics: vi.fn(async () => metrics),
report: vi.fn(async () => ({
accepted: true,
code: 'accepted',
message: 'ok',
status: refreshedStatus,
})),
noteTeamChange: vi.fn(),
enqueueStartupScan: vi.fn(),
replayPendingReports: vi.fn(),
dispatchDueNudges: vi.fn(),
buildRuntimeTurnSettledHookSettings: vi.fn(),
buildRuntimeTurnSettledEnvironment: vi.fn(),
drainRuntimeTurnSettledEvents: vi.fn(),
getQueueDiagnostics: vi.fn(() => queueDiagnostics),
dispose: vi.fn(),
};
registerTeamRoutes(app, {
...mocks.services,
memberWorkSyncFeature: memberWorkSyncFeature as any,
});
await app.ready();
try {
const diagnosticsResponse = await app.inject({
method: 'GET',
url: '/api/teams/demo-team/member-work-sync/diagnostics',
});
expect(diagnosticsResponse.statusCode).toBe(200);
expect(diagnosticsResponse.json()).toMatchObject({
teamName: 'demo-team',
queue: queueDiagnostics,
metrics,
});
const refreshResponse = await app.inject({
method: 'POST',
url: '/api/teams/demo-team/member-work-sync/bob/refresh',
});
expect(refreshResponse.statusCode).toBe(200);
expect(refreshResponse.json()).toMatchObject(refreshedStatus);
expect(memberWorkSyncFeature.refreshStatus).toHaveBeenCalledWith({
teamName: 'demo-team',
memberName: 'bob',
});
const reportResponse = await app.inject({
method: 'POST',
url: '/api/teams/demo-team/member-work-sync/report',
payload: {
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token',
taskIds: [' task-a ', '', 'task-a'],
},
});
expect(reportResponse.statusCode).toBe(200);
expect(memberWorkSyncFeature.report).toHaveBeenCalledWith({
teamName: 'demo-team',
memberName: 'bob',
state: 'still_working',
agendaFingerprint: 'agenda:v1:abc',
reportToken: 'wrs:v1.test.token',
taskIds: ['task-a'],
source: 'mcp',
});
} finally {
await app.close();
}
});
}); });

View file

@ -40,9 +40,11 @@ vi.mock('../../../../src/main/services/infrastructure/NotificationManager', () =
}, },
})); }));
const allowConnectedClaudeAccount =
process.env.MEMBER_WORK_SYNC_CLAUDE_ALLOW_CONNECTED_ACCOUNT === '1';
const liveDescribe = const liveDescribe =
process.env.MEMBER_WORK_SYNC_CLAUDE_STOP_HOOK_LIVE === '1' && process.env.MEMBER_WORK_SYNC_CLAUDE_STOP_HOOK_LIVE === '1' &&
Boolean(process.env.ANTHROPIC_API_KEY?.trim()) (hasLiveAnthropicApiKey() || allowConnectedClaudeAccount)
? describe ? describe
: describe.skip; : describe.skip;
@ -90,7 +92,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
let previousCliPath: string | undefined; let previousCliPath: string | undefined;
let previousCliFlavor: string | undefined; let previousCliFlavor: string | undefined;
let previousControlUrl: string | undefined; let previousControlUrl: string | undefined;
let previousNudgeFlag: string | undefined;
let previousDisableAppBootstrap: string | undefined; let previousDisableAppBootstrap: string | undefined;
let previousDisableRuntimeBootstrap: string | undefined; let previousDisableRuntimeBootstrap: string | undefined;
let previousHome: string | undefined; let previousHome: string | undefined;
@ -104,28 +105,30 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
beforeEach(async () => { beforeEach(async () => {
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-claude-stop-live-')); tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'member-work-sync-claude-stop-live-'));
tempClaudeRoot = path.join(tempDir, '.claude'); tempClaudeRoot = path.join(tempDir, '.claude');
tempHome = path.join(tempDir, 'home');
await fs.mkdir(tempClaudeRoot, { recursive: true }); await fs.mkdir(tempClaudeRoot, { recursive: true });
await fs.mkdir(tempHome, { recursive: true });
setClaudeBasePathOverride(tempClaudeRoot); setClaudeBasePathOverride(tempClaudeRoot);
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH; previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR; previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL; previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED;
previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
previousHome = process.env.HOME; previousHome = process.env.HOME;
previousHistFile = process.env.HISTFILE; previousHistFile = process.env.HISTFILE;
previousUserProfile = process.env.USERPROFILE; previousUserProfile = process.env.USERPROFILE;
const shouldUseConnectedAccountHome = allowConnectedClaudeAccount && !hasLiveAnthropicApiKey();
tempHome = shouldUseConnectedAccountHome
? resolveConnectedClaudeHome(previousHome)
: path.join(tempDir, 'home');
await fs.mkdir(tempHome, { recursive: true });
process.env.HOME = tempHome; process.env.HOME = tempHome;
process.env.HISTFILE = '/dev/null'; process.env.HISTFILE = '/dev/null';
process.env.USERPROFILE = tempHome; process.env.USERPROFILE = tempHome;
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator'; process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0';
delete process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; delete process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
delete process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP; delete process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
@ -148,7 +151,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath); restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor); restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
restoreEnv('CLAUDE_TEAM_CONTROL_URL', previousControlUrl); restoreEnv('CLAUDE_TEAM_CONTROL_URL', previousControlUrl);
restoreEnv('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag);
restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap); restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap);
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap); restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
restoreEnv('HOME', previousHome); restoreEnv('HOME', previousHome);
@ -163,6 +165,9 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
}); });
afterAll(async () => { afterAll(async () => {
if (process.env.MEMBER_WORK_SYNC_CLAUDE_KEEP_TEMP === '1') {
return;
}
await cleanupScopedClaudeStopHookLiveTempDirs(); await cleanupScopedClaudeStopHookLiveTempDirs();
}); });
@ -200,7 +205,6 @@ liveDescribe('Member work sync Claude Stop hook live e2e', () => {
isTeamActive: (name) => isTeamActive: (name) =>
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name), activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!], listLifecycleActiveTeamNames: async () => [teamName!],
nudgeSideEffectsEnabled: false,
queueQuietWindowMs: 500, queueQuietWindowMs: 500,
// Native Claude teammates are registered by the real lead process, but in this // Native Claude teammates are registered by the real lead process, but in this
// headless harness their bootstrap turn can finish before there is a durable // headless harness their bootstrap turn can finish before there is a durable
@ -536,3 +540,19 @@ async function cleanupScopedClaudeStopHookLiveTempDirs(): Promise<void> {
} }
} }
} }
function hasLiveAnthropicApiKey(): boolean {
return Boolean(process.env.ANTHROPIC_API_KEY?.trim());
}
function resolveConnectedClaudeHome(previousHome: string | undefined): string {
const explicit = process.env.MEMBER_WORK_SYNC_CLAUDE_CONNECTED_HOME?.trim();
if (explicit) {
return path.resolve(explicit);
}
const previous = previousHome?.trim();
if (previous) {
return path.resolve(previous);
}
return os.userInfo().homedir;
}

View file

@ -17,6 +17,7 @@ import {
FatalWaitError, FatalWaitError,
formatMemberWorkSyncDiagnostics, formatMemberWorkSyncDiagnostics,
formatProgressDump, formatProgressDump,
readRuntimeTurnSettledProcessedMetas,
restoreEnv, restoreEnv,
startMemberWorkSyncControlServer, startMemberWorkSyncControlServer,
type MemberWorkSyncLiveControlServer, type MemberWorkSyncLiveControlServer,
@ -54,8 +55,8 @@ liveDescribe('Member work sync Codex live e2e', () => {
let previousCliPath: string | undefined; let previousCliPath: string | undefined;
let previousCliFlavor: string | undefined; let previousCliFlavor: string | undefined;
let previousControlUrl: string | undefined; let previousControlUrl: string | undefined;
let previousNudgeFlag: string | undefined;
let previousCodexHome: string | undefined; let previousCodexHome: string | undefined;
let previousCodexIgnoreUserConfig: string | undefined;
let codexHomeDir: string; let codexHomeDir: string;
let ownsCodexHomeDir: boolean; let ownsCodexHomeDir: boolean;
let codexAccountFeature: { let codexAccountFeature: {
@ -71,6 +72,11 @@ liveDescribe('Member work sync Codex live e2e', () => {
hasProvisioningRun(teamName: string): boolean; hasProvisioningRun(teamName: string): boolean;
setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void; setTeamChangeEmitter(emitter: ((event: TeamChangeEvent) => void) | null): void;
setControlApiBaseUrlResolver(resolver: (() => Promise<string | null>) | null): void; setControlApiBaseUrlResolver(resolver: (() => Promise<string | null>) | null): void;
setRuntimeTurnSettledEnvironmentProvider(
provider:
| ((input: { provider: 'claude' | 'codex' | 'opencode' }) => Promise<Record<string, string> | null>)
| null
): void;
relayInboxFileToLiveRecipient(teamName: string, inboxName: string): Promise<{ relayed: number }>; relayInboxFileToLiveRecipient(teamName: string, inboxName: string): Promise<{ relayed: number }>;
createTeam( createTeam(
request: Parameters< request: Parameters<
@ -94,8 +100,8 @@ liveDescribe('Member work sync Codex live e2e', () => {
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH; previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR; previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL; previousControlUrl = process.env.CLAUDE_TEAM_CONTROL_URL;
previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED;
previousCodexHome = process.env.CODEX_HOME; previousCodexHome = process.env.CODEX_HOME;
previousCodexIgnoreUserConfig = process.env.CLAUDE_CODE_CODEX_NATIVE_IGNORE_USER_CONFIG;
const shouldUseConnectedAccountHome = allowConnectedChatGptAccount && !hasLiveCodexApiKey(); const shouldUseConnectedAccountHome = allowConnectedChatGptAccount && !hasLiveCodexApiKey();
if (shouldUseConnectedAccountHome) { if (shouldUseConnectedAccountHome) {
@ -112,8 +118,8 @@ liveDescribe('Member work sync Codex live e2e', () => {
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator'; process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0';
process.env.CODEX_HOME = codexHomeDir; process.env.CODEX_HOME = codexHomeDir;
process.env.CLAUDE_CODE_CODEX_NATIVE_IGNORE_USER_CONFIG = 'true';
codexAccountFeature = null; codexAccountFeature = null;
providerConnectionService = null; providerConnectionService = null;
@ -128,6 +134,7 @@ liveDescribe('Member work sync Codex live e2e', () => {
await svc.stopTeam(teamName).catch(() => undefined); await svc.stopTeam(teamName).catch(() => undefined);
} }
svc?.setControlApiBaseUrlResolver(null); svc?.setControlApiBaseUrlResolver(null);
svc?.setRuntimeTurnSettledEnvironmentProvider(null);
providerConnectionService?.setCodexAccountFeature(null); providerConnectionService?.setCodexAccountFeature(null);
await feature?.dispose().catch(() => undefined); await feature?.dispose().catch(() => undefined);
await codexAccountFeature?.dispose().catch(() => undefined); await codexAccountFeature?.dispose().catch(() => undefined);
@ -136,8 +143,8 @@ liveDescribe('Member work sync Codex live e2e', () => {
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath); restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor); restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
restoreEnv('CLAUDE_TEAM_CONTROL_URL', previousControlUrl); restoreEnv('CLAUDE_TEAM_CONTROL_URL', previousControlUrl);
restoreEnv('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag);
restoreEnv('CODEX_HOME', previousCodexHome); restoreEnv('CODEX_HOME', previousCodexHome);
restoreEnv('CLAUDE_CODE_CODEX_NATIVE_IGNORE_USER_CONFIG', previousCodexIgnoreUserConfig);
setClaudeBasePathOverride(null); setClaudeBasePathOverride(null);
if (process.env.MEMBER_WORK_SYNC_CODEX_KEEP_TEMP === '1') { if (process.env.MEMBER_WORK_SYNC_CODEX_KEEP_TEMP === '1') {
console.info(`[MemberWorkSyncCodex.live] preserved temp dir: ${tempDir}`); console.info(`[MemberWorkSyncCodex.live] preserved temp dir: ${tempDir}`);
@ -151,7 +158,7 @@ liveDescribe('Member work sync Codex live e2e', () => {
}); });
it( it(
'lets a real Codex teammate report still-working for the current actionable agenda without automatic nudges', 'lets a real Codex teammate report still-working for the current actionable agenda with active nudge guards',
async () => { async () => {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim(); const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy(); expect(orchestratorCli).toBeTruthy();
@ -221,11 +228,13 @@ liveDescribe('Member work sync Codex live e2e', () => {
isTeamActive: (name) => isTeamActive: (name) =>
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name), activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!], listLifecycleActiveTeamNames: async () => [teamName!],
nudgeSideEffectsEnabled: false,
}); });
activeService.setTeamChangeEmitter((event: TeamChangeEvent) => activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
feature!.noteTeamChange(event) feature!.noteTeamChange(event)
); );
activeService.setRuntimeTurnSettledEnvironmentProvider((input) =>
feature!.buildRuntimeTurnSettledEnvironment(input)
);
controlServer = await startMemberWorkSyncControlServer(feature); controlServer = await startMemberWorkSyncControlServer(feature);
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl; process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null); activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null);
@ -249,7 +258,7 @@ liveDescribe('Member work sync Codex live e2e', () => {
prompt: [ prompt: [
'Keep launch work minimal.', 'Keep launch work minimal.',
'If you receive a task, follow task instructions exactly.', 'If you receive a task, follow task instructions exactly.',
'Before going idle with unfinished assigned work, call member_work_sync_status and member_work_sync_report.', 'Do not call member_work_sync_status until a task instruction or member_work_sync_nudge provides exact teamName, memberName, and controlUrl.',
].join(' '), ].join(' '),
members: [], members: [],
}, },
@ -280,26 +289,23 @@ liveDescribe('Member work sync Codex live e2e', () => {
`This is a live member-work-sync validation task. Marker: ${marker}.`, `This is a live member-work-sync validation task. Marker: ${marker}.`,
'Do not edit files and do not complete this task.', 'Do not edit files and do not complete this task.',
'Call task_start for this task.', 'Call task_start for this task.',
`Add one task comment containing exactly: ${marker}:still-working.`,
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`, `Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`,
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlServer.baseUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`, `Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlServer.baseUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`,
`Only after member_work_sync_report is accepted, add one task comment containing exactly: ${marker}:still-working.`,
'After that stop. Do not send a user-visible message.', 'After that stop. Do not send a user-visible message.',
].join('\n'), ].join('\n'),
}); });
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id }); feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
const preRelayStatus = await feature.refreshStatus({ teamName, memberName });
expect(preRelayStatus.memberName).toBe(memberName);
expect(preRelayStatus.providerId).toBe('codex');
expect(preRelayStatus.agenda.items.some((item) => item.taskId === task.id)).toBe(true);
expect(preRelayStatus.shadow?.wouldNudge).toBe(true);
const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName); const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName);
expect(relay.relayed).toBeGreaterThan(0); expect(relay.relayed).toBeGreaterThan(0);
await waitUntil(async () => {
const status = await feature!.getStatus({ teamName: teamName!, memberName });
return (
status.memberName === memberName &&
status.providerId === 'codex' &&
status.agenda.items.some((item) => item.taskId === task.id) &&
status.shadow?.wouldNudge === true
);
}, 30_000);
await waitUntil(async () => { await waitUntil(async () => {
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!); const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);
if (fatalRuntimeMessage) { if (fatalRuntimeMessage) {
@ -335,6 +341,17 @@ liveDescribe('Member work sync Codex live e2e', () => {
state: 'still_working', state: 'still_working',
}); });
expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true); expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true);
await waitUntil(async () => {
await feature!.drainRuntimeTurnSettledEvents();
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
return metas.some(
({ meta }) =>
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.provider ===
'codex' &&
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.teamName ===
teamName
);
}, 60_000);
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({ await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
claimed: 0, claimed: 0,
delivered: 0, delivered: 0,
@ -342,6 +359,418 @@ liveDescribe('Member work sync Codex live e2e', () => {
}, },
360_000 360_000
); );
it(
'delivers a real work-sync nudge to a Codex teammate and accepts the follow-up report',
async () => {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy();
await assertExecutable(orchestratorCli!);
const model = process.env.MEMBER_WORK_SYNC_CODEX_MODEL?.trim() || DEFAULT_MODEL;
const effort = (process.env.MEMBER_WORK_SYNC_CODEX_EFFORT?.trim() ||
DEFAULT_EFFORT) as 'low' | 'medium' | 'high' | 'xhigh';
const marker = `member-work-sync-codex-nudge-${Date.now()}`;
teamName = `member-work-sync-codex-nudge-${Date.now()}`;
const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(
path.join(projectPath, 'README.md'),
'# Member work sync Codex nudge live e2e\n\nKeep this project intentionally tiny.\n',
'utf8'
);
const [
{ TeamProvisioningService },
{ TeamDataService },
{ TeamConfigReader },
{ TeamTaskReader },
{ TeamKanbanManager },
{ TeamMembersMetaStore },
{ createCodexAccountFeature },
{ ProviderConnectionService },
] = await Promise.all([
import('../../../../src/main/services/team/TeamProvisioningService'),
import('../../../../src/main/services/team/TeamDataService'),
import('../../../../src/main/services/team/TeamConfigReader'),
import('../../../../src/main/services/team/TeamTaskReader'),
import('../../../../src/main/services/team/TeamKanbanManager'),
import('../../../../src/main/services/team/TeamMembersMetaStore'),
import('../../../../src/features/codex-account/main/composition/createCodexAccountFeature'),
import('../../../../src/main/services/runtime/ProviderConnectionService'),
]);
codexAccountFeature = createCodexAccountFeature({
logger: {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
configManager: {
getConfig: () => ({
providerConnections: {
codex: {
preferredAuthMode: 'chatgpt' as const,
},
},
}),
},
});
providerConnectionService = ProviderConnectionService.getInstance();
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
svc = new TeamProvisioningService();
const activeService = svc;
const teamDataService = new TeamDataService();
feature = createMemberWorkSyncFeature({
teamsBasePath: getTeamsBasePath(),
configReader: new TeamConfigReader(),
taskReader: new TeamTaskReader(),
kanbanManager: new TeamKanbanManager(),
membersMetaStore: new TeamMembersMetaStore(),
isTeamActive: (name) =>
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!],
queueQuietWindowMs: 1,
});
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
feature!.noteTeamChange(event)
);
activeService.setRuntimeTurnSettledEnvironmentProvider((input) =>
feature!.buildRuntimeTurnSettledEnvironment(input)
);
controlServer = await startMemberWorkSyncControlServer(feature);
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null);
await fs.writeFile(
path.join(tempClaudeRoot, 'team-control-api.json'),
JSON.stringify({ baseUrl: controlServer.baseUrl }, null, 2),
'utf8'
);
const progressEvents: TeamProvisioningProgress[] = [];
await activeService.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'codex',
providerBackendId: 'codex-native',
model,
effort,
fastMode: 'off',
skipPermissions: true,
prompt: [
'Keep launch work minimal.',
'If you receive a member_work_sync_nudge, do not complete the task.',
'For a member_work_sync_nudge, call member_work_sync_status first, then call member_work_sync_report with state "still_working", the returned agendaFingerprint/reportToken, and taskIds for the current agenda.',
'After reporting, stop without a user-visible message.',
].join(' '),
members: [],
},
(progress) => {
progressEvents.push(progress);
}
);
await waitUntil(async () => {
const last = progressEvents.at(-1);
if (last?.state === 'failed') {
throw new Error(formatProgressDump(progressEvents));
}
return last?.state === 'ready';
}, 240_000);
const config = await new TeamConfigReader().getConfig(teamName);
const memberName =
config?.members?.find((member) => member.agentType === 'team-lead')?.name?.trim() ||
config?.members?.find((member) => member.role?.toLowerCase().includes('lead'))?.name?.trim() ||
config?.members?.[0]?.name?.trim() ||
'team-lead';
await seedShadowReadyMetrics({ teamName, memberName });
const task = await teamDataService.createTask(teamName, {
subject: `Member work sync live nudge ${marker}`,
owner: memberName,
startImmediately: false,
prompt: [
`This is a live member-work-sync nudge validation task. Marker: ${marker}.`,
'Do not edit files and do not complete this task.',
'Only report still_working if member-work-sync asks you to synchronize.',
].join('\n'),
});
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
await waitUntil(async () => {
const status = await feature!.getStatus({ teamName: teamName!, memberName });
if (!status.agenda.items.some((item) => item.taskId === task.id)) {
return false;
}
const inbox = await readInboxMessages(teamName!, memberName);
return inbox.some(
(message) =>
message.messageKind === 'member_work_sync_nudge' &&
typeof message.messageId === 'string' &&
message.text.includes('Work sync check')
);
}, 60_000, 500, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName,
taskId: task.id,
})
);
const inbox = await readInboxMessages(teamName, memberName);
const nudge = inbox.find((message) => message.messageKind === 'member_work_sync_nudge');
expect(nudge?.messageId).toBeTruthy();
const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName);
expect(relay.relayed).toBeGreaterThan(0);
await waitUntil(async () => {
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);
if (fatalRuntimeMessage) {
throw new FatalWaitError(fatalRuntimeMessage);
}
await feature!.replayPendingReports([teamName!]);
const status = await feature!.getStatus({ teamName: teamName!, memberName });
return status.report?.accepted === true && status.report.state === 'still_working';
}, 240_000, 2_000, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName,
taskId: task.id,
})
);
const finalStatus = await feature.getStatus({ teamName, memberName });
expect(finalStatus.state).toBe('still_working');
expect(finalStatus.report).toMatchObject({
accepted: true,
state: 'still_working',
});
await waitUntil(async () => {
await feature!.drainRuntimeTurnSettledEvents();
const metas = await readRuntimeTurnSettledProcessedMetas(getTeamsBasePath());
return metas.some(
({ meta }) =>
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.provider ===
'codex' &&
(meta.event as { provider?: unknown; teamName?: unknown } | undefined)?.teamName ===
teamName
);
}, 60_000);
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
claimed: 0,
delivered: 0,
});
},
420_000
);
it(
'lets a real Codex teammate complete the task and report caught-up after the board clears',
async () => {
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
expect(orchestratorCli).toBeTruthy();
await assertExecutable(orchestratorCli!);
const model = process.env.MEMBER_WORK_SYNC_CODEX_MODEL?.trim() || DEFAULT_MODEL;
const effort = (process.env.MEMBER_WORK_SYNC_CODEX_EFFORT?.trim() ||
DEFAULT_EFFORT) as 'low' | 'medium' | 'high' | 'xhigh';
const marker = `member-work-sync-codex-complete-${Date.now()}`;
teamName = `member-work-sync-codex-complete-${Date.now()}`;
const projectPath = path.join(tempDir, 'project');
await fs.mkdir(projectPath, { recursive: true });
await fs.writeFile(
path.join(projectPath, 'README.md'),
'# Member work sync Codex complete live e2e\n\nKeep this project intentionally tiny.\n',
'utf8'
);
const [
{ TeamProvisioningService },
{ TeamDataService },
{ TeamConfigReader },
{ TeamTaskReader },
{ TeamKanbanManager },
{ TeamMembersMetaStore },
{ createCodexAccountFeature },
{ ProviderConnectionService },
] = await Promise.all([
import('../../../../src/main/services/team/TeamProvisioningService'),
import('../../../../src/main/services/team/TeamDataService'),
import('../../../../src/main/services/team/TeamConfigReader'),
import('../../../../src/main/services/team/TeamTaskReader'),
import('../../../../src/main/services/team/TeamKanbanManager'),
import('../../../../src/main/services/team/TeamMembersMetaStore'),
import('../../../../src/features/codex-account/main/composition/createCodexAccountFeature'),
import('../../../../src/main/services/runtime/ProviderConnectionService'),
]);
codexAccountFeature = createCodexAccountFeature({
logger: {
info: () => undefined,
warn: () => undefined,
error: () => undefined,
},
configManager: {
getConfig: () => ({
providerConnections: {
codex: {
preferredAuthMode: hasLiveCodexApiKey() ? 'auto' : ('chatgpt' as const),
},
},
}),
},
});
providerConnectionService = ProviderConnectionService.getInstance();
providerConnectionService.setCodexAccountFeature(codexAccountFeature);
svc = new TeamProvisioningService();
const activeService = svc;
const teamDataService = new TeamDataService();
const taskReader = new TeamTaskReader();
feature = createMemberWorkSyncFeature({
teamsBasePath: getTeamsBasePath(),
configReader: new TeamConfigReader(),
taskReader,
kanbanManager: new TeamKanbanManager(),
membersMetaStore: new TeamMembersMetaStore(),
isTeamActive: (name) =>
activeService.isTeamAlive(name) || activeService.hasProvisioningRun(name),
listLifecycleActiveTeamNames: async () => [teamName!],
queueQuietWindowMs: 1,
});
activeService.setTeamChangeEmitter((event: TeamChangeEvent) =>
feature!.noteTeamChange(event)
);
activeService.setRuntimeTurnSettledEnvironmentProvider((input) =>
feature!.buildRuntimeTurnSettledEnvironment(input)
);
controlServer = await startMemberWorkSyncControlServer(feature);
process.env.CLAUDE_TEAM_CONTROL_URL = controlServer.baseUrl;
activeService.setControlApiBaseUrlResolver(async () => controlServer?.baseUrl ?? null);
await fs.writeFile(
path.join(tempClaudeRoot, 'team-control-api.json'),
JSON.stringify({ baseUrl: controlServer.baseUrl }, null, 2),
'utf8'
);
const progressEvents: TeamProvisioningProgress[] = [];
await activeService.createTeam(
{
teamName,
cwd: projectPath,
providerId: 'codex',
providerBackendId: 'codex-native',
model,
effort,
fastMode: 'off',
skipPermissions: true,
prompt: [
'Keep launch work minimal.',
'If you receive a task, follow task instructions exactly.',
'Use member_work_sync_status and member_work_sync_report whenever the task asks you to synchronize work state.',
].join(' '),
members: [],
},
(progress) => {
progressEvents.push(progress);
}
);
await waitUntil(async () => {
const last = progressEvents.at(-1);
if (last?.state === 'failed') {
throw new Error(formatProgressDump(progressEvents));
}
return last?.state === 'ready';
}, 240_000);
const config = await new TeamConfigReader().getConfig(teamName);
const memberName =
config?.members?.find((member) => member.agentType === 'team-lead')?.name?.trim() ||
config?.members?.find((member) => member.role?.toLowerCase().includes('lead'))?.name?.trim() ||
config?.members?.[0]?.name?.trim() ||
'team-lead';
await seedShadowReadyMetrics({ teamName, memberName });
const task = await teamDataService.createTask(teamName, {
subject: `Member work sync live completion ${marker}`,
owner: memberName,
startImmediately: true,
prompt: [
`This is a live member-work-sync completion validation task. Marker: ${marker}.`,
'Do not edit files.',
'Call task_start for this task.',
`Then call member_work_sync_status with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`,
`Then call member_work_sync_report with teamName "${teamName}", memberName "${memberName}", controlUrl "${controlServer.baseUrl}", state "still_working", the exact agendaFingerprint and reportToken returned by member_work_sync_status, and the current task id if available.`,
'After that, call task_complete for this task.',
`Then call member_work_sync_status again with teamName "${teamName}", memberName "${memberName}", and controlUrl "${controlServer.baseUrl}".`,
'If the returned agenda has no items, call member_work_sync_report with state "caught_up", no taskIds, and the exact agendaFingerprint/reportToken returned by that second status call.',
`Only after the caught_up report is accepted, add one task comment containing exactly: ${marker}:completed-and-caught-up.`,
'After that stop. Do not send a user-visible message.',
].join('\n'),
});
feature.noteTeamChange({ type: 'task', teamName, taskId: task.id });
const relay = await activeService.relayInboxFileToLiveRecipient(teamName, memberName);
expect(relay.relayed).toBeGreaterThan(0);
await waitUntil(async () => {
const fatalRuntimeMessage = await readFatalRuntimeMessage(teamName!);
if (fatalRuntimeMessage) {
throw new FatalWaitError(fatalRuntimeMessage);
}
await feature!.replayPendingReports([teamName!]);
await feature!.drainRuntimeTurnSettledEvents();
const [tasks, status] = await Promise.all([
taskReader.getTasks(teamName!),
feature!.refreshStatus({ teamName: teamName!, memberName }),
]);
const currentTask = tasks.find((candidate) => candidate.id === task.id);
const hasCompletionMarker = currentTask?.comments?.some((comment) =>
comment.text.includes(`${marker}:completed-and-caught-up`)
);
return Boolean(
currentTask?.status === 'completed' &&
hasCompletionMarker &&
status.state === 'caught_up' &&
status.agenda.items.length === 0 &&
status.report?.accepted === true &&
status.report.state === 'caught_up'
);
}, 300_000, 2_000, async () =>
formatMemberWorkSyncDiagnostics({
feature: feature!,
teamName: teamName!,
memberName,
taskId: task.id,
})
);
const [tasks, finalStatus, metrics] = await Promise.all([
taskReader.getTasks(teamName),
feature.getStatus({ teamName, memberName }),
feature.getMetrics({ teamName }),
]);
const completedTask = tasks.find((candidate) => candidate.id === task.id);
expect(completedTask?.status).toBe('completed');
expect(finalStatus.state).toBe('caught_up');
expect(finalStatus.agenda.items).toEqual([]);
expect(finalStatus.report).toMatchObject({
accepted: true,
state: 'caught_up',
});
expect(metrics.recentEvents.some((event) => event.kind === 'report_accepted')).toBe(true);
await expect(feature.dispatchDueNudges([teamName])).resolves.toMatchObject({
claimed: 0,
delivered: 0,
});
},
480_000
);
}); });
async function readFatalRuntimeMessage(teamName: string): Promise<string | null> { async function readFatalRuntimeMessage(teamName: string): Promise<string | null> {
@ -397,3 +826,86 @@ function resolveConnectedCodexHome(previousCodexHome: string | undefined): strin
} }
return path.join(os.userInfo().homedir, '.codex'); return path.join(os.userInfo().homedir, '.codex');
} }
async function seedShadowReadyMetrics(input: {
teamName: string;
memberName: string;
}): Promise<void> {
const metricsPath = path.join(
getTeamsBasePath(),
input.teamName,
'.member-work-sync',
'indexes',
'metrics.json'
);
const startMs = Date.now() - 2 * 60 * 60_000;
await fs.mkdir(path.dirname(metricsPath), { recursive: true });
await fs.writeFile(
metricsPath,
`${JSON.stringify(
{
schemaVersion: 2,
members: {
[input.memberName]: {
memberName: input.memberName,
state: 'caught_up',
agendaFingerprint: 'agenda:v1:seed',
actionableCount: 0,
evaluatedAt: new Date(startMs).toISOString(),
providerId: 'codex',
},
},
recentEvents: Array.from({ length: 24 }, (_, index) => ({
id: `seed-status-${index}`,
teamName: input.teamName,
memberName: input.memberName,
kind: 'status_evaluated',
state: 'caught_up',
agendaFingerprint: `agenda:v1:seed-${index}`,
recordedAt: new Date(startMs + index * 6 * 60_000).toISOString(),
actionableCount: 0,
providerId: 'codex',
})),
},
null,
2
)}\n`,
'utf8'
);
}
async function readInboxMessages(teamName: string, memberName: string): Promise<
Array<{
messageId?: string;
messageKind?: string;
text: string;
read?: boolean;
}>
> {
const inboxPath = path.join(getTeamsBasePath(), teamName, 'inboxes', `${memberName}.json`);
const raw = await fs.readFile(inboxPath, 'utf8').catch(() => '[]');
const parsed = JSON.parse(raw) as unknown;
if (!Array.isArray(parsed)) {
return [];
}
return parsed
.filter((message): message is Record<string, unknown> =>
Boolean(message) && typeof message === 'object'
)
.flatMap((message) => {
const text = typeof message.text === 'string' ? message.text : '';
if (!text) {
return [];
}
return [
{
...(typeof message.messageId === 'string' ? { messageId: message.messageId } : {}),
...(typeof message.messageKind === 'string'
? { messageKind: message.messageKind }
: {}),
text,
...(typeof message.read === 'boolean' ? { read: message.read } : {}),
},
];
});
}

View file

@ -46,7 +46,6 @@ liveDescribe('Mixed provider team launch live e2e', () => {
let projectPath: string; let projectPath: string;
let previousCliPath: string | undefined; let previousCliPath: string | undefined;
let previousCliFlavor: string | undefined; let previousCliFlavor: string | undefined;
let previousNudgeFlag: string | undefined;
let previousCodexHome: string | undefined; let previousCodexHome: string | undefined;
let previousHome: string | undefined; let previousHome: string | undefined;
let previousUserProfile: string | undefined; let previousUserProfile: string | undefined;
@ -78,7 +77,6 @@ liveDescribe('Mixed provider team launch live e2e', () => {
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH; previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR; previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
previousNudgeFlag = process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED;
previousCodexHome = process.env.CODEX_HOME; previousCodexHome = process.env.CODEX_HOME;
previousHome = process.env.HOME; previousHome = process.env.HOME;
previousUserProfile = process.env.USERPROFILE; previousUserProfile = process.env.USERPROFILE;
@ -89,7 +87,6 @@ liveDescribe('Mixed provider team launch live e2e', () => {
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI; process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator'; process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
process.env.CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED = '0';
process.env.CODEX_HOME = resolveConnectedCodexHome(previousCodexHome); process.env.CODEX_HOME = resolveConnectedCodexHome(previousCodexHome);
process.env.HOME = tempHome; process.env.HOME = tempHome;
process.env.USERPROFILE = tempHome; process.env.USERPROFILE = tempHome;
@ -118,7 +115,6 @@ liveDescribe('Mixed provider team launch live e2e', () => {
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath); restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor); restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
restoreEnv('CLAUDE_TEAM_MEMBER_WORK_SYNC_NUDGES_ENABLED', previousNudgeFlag);
restoreEnv('CODEX_HOME', previousCodexHome); restoreEnv('CODEX_HOME', previousCodexHome);
restoreEnv('HOME', previousHome); restoreEnv('HOME', previousHome);
restoreEnv('USERPROFILE', previousUserProfile); restoreEnv('USERPROFILE', previousUserProfile);

View file

@ -125,6 +125,7 @@ describe('OpenCode production prompt artifacts safe e2e', () => {
expect(directCommand?.text).toContain('Include source="runtime_delivery"'); expect(directCommand?.text).toContain('Include source="runtime_delivery"');
expect(directCommand?.text).toContain('Include relayOfMessageId="semantic-direct-'); expect(directCommand?.text).toContain('Include relayOfMessageId="semantic-direct-');
expect(directCommand?.text).toContain('Action mode for this message: ask.'); expect(directCommand?.text).toContain('Action mode for this message: ask.');
expect(directCommand?.text).toContain('You must not end this turn empty.');
expect(directCommand?.text).toContain('"displayId":"59560c95"'); expect(directCommand?.text).toContain('"displayId":"59560c95"');
expect(directCommand?.text).toContain('Do not use SendMessage or runtime_deliver_message'); expect(directCommand?.text).toContain('Do not use SendMessage or runtime_deliver_message');
expect(directCommand?.text).toContain('never use #00000000'); expect(directCommand?.text).toContain('never use #00000000');

View file

@ -133,15 +133,63 @@ describe('OpenCodePromptDeliveryLedger', () => {
await expect(store.list()).resolves.toHaveLength(1); await expect(store.list()).resolves.toHaveLength(1);
}); });
it('upgrades legacy pending records with message kind without changing payload identity', async () => {
const store = createStore();
const payloadHash = hashOpenCodePromptDeliveryPayload({
text: 'Work sync check',
replyRecipient: 'team-lead',
actionMode: 'do',
source: 'watcher',
});
const legacy = await store.ensurePending({
teamName: 'team-a',
memberName: 'jack',
laneId: 'secondary:opencode:jack',
inboxMessageId: 'msg-work-sync',
inboxTimestamp: '2026-04-25T09:59:00.000Z',
source: 'watcher',
replyRecipient: 'team-lead',
actionMode: 'do',
taskRefs: [],
payloadHash,
now: '2026-04-25T10:00:00.000Z',
});
const envelope = JSON.parse(await fs.readFile(ledgerPath(), 'utf8')) as {
data: Record<string, unknown>[];
};
delete envelope.data[0].messageKind;
await fs.writeFile(ledgerPath(), `${JSON.stringify(envelope, null, 2)}\n`, 'utf8');
const upgraded = await store.ensurePending({
teamName: 'team-a',
memberName: 'jack',
laneId: 'secondary:opencode:jack',
inboxMessageId: 'msg-work-sync',
inboxTimestamp: '2026-04-25T09:59:00.000Z',
source: 'watcher',
messageKind: 'member_work_sync_nudge',
replyRecipient: 'team-lead',
actionMode: 'do',
taskRefs: [],
payloadHash,
now: '2026-04-25T10:00:30.000Z',
});
expect(upgraded.id).toBe(legacy.id);
expect(upgraded.messageKind).toBe('member_work_sync_nudge');
expect(upgraded.payloadHash).toBe(payloadHash);
expect(upgraded.attempts).toBe(0);
await expect(store.list()).resolves.toHaveLength(1);
});
it.each(corruptionCases)('rejects corrupted persisted records with %s', async (_name, mutate) => { it.each(corruptionCases)('rejects corrupted persisted records with %s', async (_name, mutate) => {
const store = await writeCorruptedLedgerRecord(mutate); const store = await writeCorruptedLedgerRecord(mutate);
await expect(store.list()).rejects.toMatchObject({ await expect(store.list()).rejects.toMatchObject({
reason: 'invalid_data', reason: 'invalid_data',
}); });
await expect(fs.readdir(tempDir)).resolves.toContain( await expect(fs.readdir(tempDir)).resolves.toContain('opencode-prompt-delivery-ledger.json');
'opencode-prompt-delivery-ledger.json'
);
expect((await fs.readdir(tempDir)).some((name) => name.includes('.invalid_data.'))).toBe(true); expect((await fs.readdir(tempDir)).some((name) => name.includes('.invalid_data.'))).toBe(true);
}); });
@ -211,12 +259,12 @@ describe('OpenCodePromptDeliveryLedger', () => {
reason: 'visible_reply_ack_only_still_requires_answer', reason: 'visible_reply_ack_only_still_requires_answer',
scheduledAt: '2026-04-25T10:00:02.000Z', scheduledAt: '2026-04-25T10:00:02.000Z',
}); });
expect(isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:29.000Z'))).toBe( expect(
false isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:29.000Z'))
); ).toBe(false);
expect(isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:30.000Z'))).toBe( expect(
true isOpenCodePromptDeliveryAttemptDue(scheduled, Date.parse('2026-04-25T10:00:30.000Z'))
); ).toBe(true);
}); });
it('records empty assistant delivery results as unanswered and stores plain text previews', async () => { it('records empty assistant delivery results as unanswered and stores plain text previews', async () => {
@ -255,6 +303,38 @@ describe('OpenCodePromptDeliveryLedger', () => {
expect(emptyResult.responseState).toBe('empty_assistant_turn'); expect(emptyResult.responseState).toBe('empty_assistant_turn');
expect(emptyResult.attempts).toBe(1); expect(emptyResult.attempts).toBe(1);
const noAssistant = await store.ensurePending({
teamName: 'team-a',
memberName: 'jack',
laneId: 'secondary:opencode:jack',
inboxMessageId: 'msg-no-assistant',
inboxTimestamp: '2026-04-25T09:59:05.000Z',
source: 'watcher',
replyRecipient: 'user',
payloadHash: 'sha256:no-assistant',
now: '2026-04-25T10:00:06.000Z',
});
const noAssistantResult = await store.applyDeliveryResult({
id: noAssistant.id,
accepted: true,
attempted: true,
responseObservation: {
state: 'prompt_delivered_no_assistant_message',
deliveredUserMessageId: 'oc-user-no-assistant',
assistantMessageId: null,
toolCallNames: [],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: 'prompt_delivered_no_assistant_message',
},
now: '2026-04-25T10:00:07.000Z',
});
expect(noAssistantResult.status).toBe('unanswered');
expect(noAssistantResult.responseState).toBe('prompt_delivered_no_assistant_message');
const plain = await store.ensurePending({ const plain = await store.ensurePending({
teamName: 'team-a', teamName: 'team-a',
memberName: 'jack', memberName: 'jack',
@ -321,11 +401,13 @@ describe('OpenCodePromptDeliveryLedger', () => {
}); });
expect(responded.status).toBe('responded'); expect(responded.status).toBe('responded');
await expect(store.getActiveForMember({ await expect(
teamName: 'team-a', store.getActiveForMember({
memberName: 'jack', teamName: 'team-a',
laneId: 'secondary:opencode:jack', memberName: 'jack',
})).resolves.toMatchObject({ laneId: 'secondary:opencode:jack',
})
).resolves.toMatchObject({
id: record.id, id: record.id,
responseState: 'responded_plain_text', responseState: 'responded_plain_text',
}); });
@ -344,11 +426,13 @@ describe('OpenCodePromptDeliveryLedger', () => {
visibleReplyCorrelation: 'plain_assistant_text', visibleReplyCorrelation: 'plain_assistant_text',
}); });
await expect(store.getActiveForMember({ await expect(
teamName: 'team-a', store.getActiveForMember({
memberName: 'jack', teamName: 'team-a',
laneId: 'secondary:opencode:jack', memberName: 'jack',
})).resolves.toBeNull(); laneId: 'secondary:opencode:jack',
})
).resolves.toBeNull();
}); });
it('does not keep responded live deliveries active when no inbox commit is needed', async () => { it('does not keep responded live deliveries active when no inbox commit is needed', async () => {
@ -387,11 +471,13 @@ describe('OpenCodePromptDeliveryLedger', () => {
expect(responded.status).toBe('responded'); expect(responded.status).toBe('responded');
expect(responded.inboxReadCommittedAt).toBeNull(); expect(responded.inboxReadCommittedAt).toBeNull();
await expect(store.getActiveForMember({ await expect(
teamName: 'team-a', store.getActiveForMember({
memberName: 'bob', teamName: 'team-a',
laneId: 'secondary:opencode:bob', memberName: 'bob',
})).resolves.toBeNull(); laneId: 'secondary:opencode:bob',
})
).resolves.toBeNull();
const peer = await store.ensurePending({ const peer = await store.ensurePending({
teamName: 'team-a', teamName: 'team-a',
@ -407,11 +493,13 @@ describe('OpenCodePromptDeliveryLedger', () => {
now: '2026-04-25T10:01:00.000Z', now: '2026-04-25T10:01:00.000Z',
}); });
await expect(store.getActiveForMember({ await expect(
teamName: 'team-a', store.getActiveForMember({
memberName: 'bob', teamName: 'team-a',
laneId: 'secondary:opencode:bob', memberName: 'bob',
})).resolves.toMatchObject({ laneId: 'secondary:opencode:bob',
})
).resolves.toMatchObject({
id: peer.id, id: peer.id,
inboxMessageId: 'peer-relay', inboxMessageId: 'peer-relay',
}); });
@ -553,21 +641,25 @@ describe('OpenCodePromptDeliveryLedger', () => {
now: '2026-04-25T10:00:00.000Z', now: '2026-04-25T10:00:00.000Z',
}); });
await expect(store.pruneTerminalRecords({ await expect(
now: new Date('2026-04-25T10:00:20.000Z'), store.pruneTerminalRecords({
respondedRetentionMs: 10_000, now: new Date('2026-04-25T10:00:20.000Z'),
failedRetentionMs: 30_000, respondedRetentionMs: 10_000,
})).resolves.toEqual({ pruned: 1, remaining: 2 }); failedRetentionMs: 30_000,
})
).resolves.toEqual({ pruned: 1, remaining: 2 });
expect((await store.list()).map((record) => record.inboxMessageId).sort()).toEqual([ expect((await store.list()).map((record) => record.inboxMessageId).sort()).toEqual([
active.inboxMessageId, active.inboxMessageId,
failed.inboxMessageId, failed.inboxMessageId,
]); ]);
await expect(store.pruneTerminalRecords({ await expect(
now: new Date('2026-04-25T10:00:40.000Z'), store.pruneTerminalRecords({
respondedRetentionMs: 10_000, now: new Date('2026-04-25T10:00:40.000Z'),
failedRetentionMs: 30_000, respondedRetentionMs: 10_000,
})).resolves.toEqual({ pruned: 1, remaining: 1 }); failedRetentionMs: 30_000,
})
).resolves.toEqual({ pruned: 1, remaining: 1 });
expect((await store.list()).map((record) => record.inboxMessageId)).toEqual([ expect((await store.list()).map((record) => record.inboxMessageId)).toEqual([
active.inboxMessageId, active.inboxMessageId,
]); ]);

View file

@ -531,6 +531,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
expect(sentText).toContain('Include source="runtime_delivery"'); expect(sentText).toContain('Include source="runtime_delivery"');
expect(sentText).toContain('Include relayOfMessageId="msg-1"'); expect(sentText).toContain('Include relayOfMessageId="msg-1"');
expect(sentText).toContain('Action mode for this message: delegate.'); expect(sentText).toContain('Action mode for this message: delegate.');
expect(sentText).toContain('You must not end this turn empty.');
expect(sentText).toContain('<opencode_delivery_context>'); expect(sentText).toContain('<opencode_delivery_context>');
expect(sentText).toContain('"kind":"opencode-delivery-context"'); expect(sentText).toContain('"kind":"opencode-delivery-context"');
expect(sentText).toContain('"inboundMessageId":"msg-1"'); expect(sentText).toContain('"inboundMessageId":"msg-1"');
@ -540,6 +541,57 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
expect(sentText).toContain('never use #00000000'); expect(sentText).toContain('never use #00000000');
}); });
it('sends member work sync nudges with report-oriented response instructions', async () => {
const sendOpenCodeTeamMessage = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
>(async () => ({
accepted: true,
sessionId: 'oc-session-bob',
memberName: 'bob',
diagnostics: [],
}));
const adapter = new OpenCodeTeamRuntimeAdapter(
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
sendOpenCodeTeamMessage,
})
);
await adapter.sendMessageToMember({
runId: 'run-1',
teamName: 'team-a',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
text: 'Work sync check',
messageId: 'msg-work-sync',
replyRecipient: 'team-lead',
actionMode: 'do',
messageKind: 'member_work_sync_nudge',
taskRefs: [{ taskId: 'task-1', displayId: 'abcd1234', teamName: 'team-a' }],
});
expect(sendOpenCodeTeamMessage).toHaveBeenCalledWith(
expect.objectContaining({
messageKind: 'member_work_sync_nudge',
actionMode: 'do',
})
);
const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? '';
expect(sentText).toContain('"messageKind":"member_work_sync_nudge"');
expect(sentText).toContain('This delivered app message is a member-work-sync nudge.');
expect(sentText).toContain('agent-teams_member_work_sync_status');
expect(sentText).toContain('agent-teams_member_work_sync_report');
expect(sentText).toContain('mcp__agent-teams__member_work_sync_report');
expect(sentText).toContain('teamName="team-a"');
expect(sentText).toContain('memberName="bob"');
expect(sentText).toContain('taskIds: "task-1"');
expect(sentText).toContain(
'Do not use provider names, runtime names, or team names as memberName'
);
expect(sentText).not.toContain('Include relayOfMessageId="msg-work-sync"');
expect(sentText).not.toContain('You must not end this turn empty.');
});
it('does not parse legacy native SendMessage wording to infer OpenCode reply recipient', async () => { it('does not parse legacy native SendMessage wording to infer OpenCode reply recipient', async () => {
const sendOpenCodeTeamMessage = vi.fn< const sendOpenCodeTeamMessage = vi.fn<
NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']> NonNullable<OpenCodeTeamRuntimeBridgePort['sendOpenCodeTeamMessage']>
@ -567,7 +619,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? ''; const sentText = sendOpenCodeTeamMessage.mock.calls[0]?.[0]?.text ?? '';
expect(sentText).toContain('Use teamName="team-a", to="user", from="bob", text, and summary.'); expect(sentText).toContain('Use teamName="team-a", to="user", from="bob", text, and summary.');
expect(sentText).not.toContain('Use teamName="team-a", to="alice", from="bob", text, and summary.'); expect(sentText).not.toContain(
'Use teamName="team-a", to="alice", from="bob", text, and summary.'
);
}); });
it('keeps missing bridge members pending while reconcile is still launching', async () => { it('keeps missing bridge members pending while reconcile is still launching', async () => {

View file

@ -10135,7 +10135,11 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.messageInputs).toHaveLength(2); expect(adapter.messageInputs).toHaveLength(2);
expect(adapter.messageInputs[0]).toMatchObject({ expect(adapter.messageInputs[0]).toMatchObject({
runId: freshRun.runId, runId: latestOpenCodeLaunchRunId(
adapter,
cancelledTeamName,
'secondary:opencode:bob'
),
teamName: cancelledTeamName, teamName: cancelledTeamName,
laneId: 'secondary:opencode:bob', laneId: 'secondary:opencode:bob',
memberName: 'bob', memberName: 'bob',
@ -10144,7 +10148,11 @@ describe('Team agent launch matrix safe e2e', () => {
messageId: 'msg-fresh-mixed-opencode-after-cancelled-handoff', messageId: 'msg-fresh-mixed-opencode-after-cancelled-handoff',
}); });
expect(adapter.messageInputs[1]).toMatchObject({ expect(adapter.messageInputs[1]).toMatchObject({
runId: survivingRun.runId, runId: latestOpenCodeLaunchRunId(
adapter,
survivingTeamName,
'secondary:opencode:tom'
),
teamName: survivingTeamName, teamName: survivingTeamName,
laneId: 'secondary:opencode:tom', laneId: 'secondary:opencode:tom',
memberName: 'tom', memberName: 'tom',
@ -10247,7 +10255,11 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.messageInputs).toHaveLength(1); expect(adapter.messageInputs).toHaveLength(1);
expect(adapter.messageInputs[0]).toMatchObject({ expect(adapter.messageInputs[0]).toMatchObject({
runId: survivingRun.runId, runId: latestOpenCodeLaunchRunId(
adapter,
survivingTeamName,
'secondary:opencode:bob'
),
teamName: survivingTeamName, teamName: survivingTeamName,
laneId: 'secondary:opencode:bob', laneId: 'secondary:opencode:bob',
memberName: 'bob', memberName: 'bob',
@ -11818,7 +11830,7 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.messageInputs).toHaveLength(1); expect(adapter.messageInputs).toHaveLength(1);
expect(adapter.messageInputs[0]).toMatchObject({ expect(adapter.messageInputs[0]).toMatchObject({
runId: run.runId, runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:tom'),
teamName, teamName,
laneId: 'secondary:opencode:tom', laneId: 'secondary:opencode:tom',
memberName: 'tom', memberName: 'tom',
@ -11861,7 +11873,7 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.messageInputs).toHaveLength(1); expect(adapter.messageInputs).toHaveLength(1);
expect(adapter.messageInputs[0]).toMatchObject({ expect(adapter.messageInputs[0]).toMatchObject({
runId: run.runId, runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:bob'),
teamName, teamName,
laneId: 'secondary:opencode:bob', laneId: 'secondary:opencode:bob',
memberName: 'bob', memberName: 'bob',
@ -11932,7 +11944,7 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.messageInputs).toHaveLength(1); expect(adapter.messageInputs).toHaveLength(1);
expect(adapter.messageInputs[0]).toMatchObject({ expect(adapter.messageInputs[0]).toMatchObject({
runId: run.runId, runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:tom'),
teamName, teamName,
laneId: 'secondary:opencode:tom', laneId: 'secondary:opencode:tom',
memberName: 'tom', memberName: 'tom',
@ -12009,7 +12021,7 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.messageInputs).toHaveLength(1); expect(adapter.messageInputs).toHaveLength(1);
expect(adapter.messageInputs[0]).toMatchObject({ expect(adapter.messageInputs[0]).toMatchObject({
runId: run.runId, runId: latestOpenCodeLaunchRunId(adapter, teamName, 'secondary:opencode:bob'),
teamName, teamName,
laneId: 'secondary:opencode:bob', laneId: 'secondary:opencode:bob',
memberName: 'bob', memberName: 'bob',
@ -12099,7 +12111,11 @@ describe('Team agent launch matrix safe e2e', () => {
expect(adapter.messageInputs).toHaveLength(2); expect(adapter.messageInputs).toHaveLength(2);
expect(adapter.messageInputs[0]).toMatchObject({ expect(adapter.messageInputs[0]).toMatchObject({
runId: firstRun.runId, runId: latestOpenCodeLaunchRunId(
adapter,
firstTeamName,
'secondary:opencode:bob'
),
teamName: firstTeamName, teamName: firstTeamName,
laneId: 'secondary:opencode:bob', laneId: 'secondary:opencode:bob',
memberName: 'bob', memberName: 'bob',
@ -12108,7 +12124,11 @@ describe('Team agent launch matrix safe e2e', () => {
messageId: 'msg-cross-team-detach-first-bob', messageId: 'msg-cross-team-detach-first-bob',
}); });
expect(adapter.messageInputs[1]).toMatchObject({ expect(adapter.messageInputs[1]).toMatchObject({
runId: secondRun.runId, runId: latestOpenCodeLaunchRunId(
adapter,
secondTeamName,
'secondary:opencode:tom'
),
teamName: secondTeamName, teamName: secondTeamName,
laneId: 'secondary:opencode:tom', laneId: 'secondary:opencode:tom',
memberName: 'tom', memberName: 'tom',
@ -17065,6 +17085,18 @@ class BlockingOpenCodeRuntimeAdapter extends FakeOpenCodeRuntimeAdapter {
} }
} }
function latestOpenCodeLaunchRunId(
adapter: { readonly launchInputs: readonly TeamRuntimeLaunchInput[] },
teamName: string,
laneId: string
): string {
const launchInput = [...adapter.launchInputs]
.reverse()
.find((input) => input.teamName === teamName && input.laneId === laneId);
expect(launchInput?.runId).toBeTruthy();
return launchInput!.runId;
}
class BlockingStopOpenCodeRuntimeAdapter extends BlockingOpenCodeRuntimeAdapter { class BlockingStopOpenCodeRuntimeAdapter extends BlockingOpenCodeRuntimeAdapter {
private releaseStopGate: (() => void) | null = null; private releaseStopGate: (() => void) | null = null;
private readonly stopGate = new Promise<void>((resolve) => { private readonly stopGate = new Promise<void>((resolve) => {

View file

@ -431,6 +431,61 @@ async function writeDefaultBobOpenCodeBootstrapEvidence(): Promise<void> {
}); });
} }
async function configureOpenCodeBobDeliveryService(input: {
svc: TeamProvisioningService;
sendMessageToMember: ReturnType<typeof vi.fn>;
observeMessageDelivery?: ReturnType<typeof vi.fn>;
}): Promise<void> {
const registry = new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember: input.sendMessageToMember,
observeMessageDelivery: input.observeMessageDelivery ?? vi.fn(),
} as any,
]);
input.svc.setRuntimeAdapterRegistry(registry);
(input.svc as any).getTrackedRunId = vi.fn(() => 'run-1');
(input.svc as any).provisioningRunByTeam.set('team-a', 'run-1');
(input.svc as any).setSecondaryRuntimeRun({
teamName: 'team-a',
runId: 'opencode-run-bob',
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo',
});
await writeDefaultBobOpenCodeBootstrapEvidence();
(input.svc as any).configReader = {
getConfig: vi.fn(async () => ({
projectPath: '/repo',
members: [
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
],
})),
};
(input.svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(input.svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
}
function createMemberSpawnStatusEntry( function createMemberSpawnStatusEntry(
overrides: Record<string, unknown> = {} overrides: Record<string, unknown> = {}
): Record<string, unknown> { ): Record<string, unknown> {
@ -583,7 +638,6 @@ describe('TeamProvisioningService', () => {
await expect(svc.warmup()).resolves.not.toThrow(); await expect(svc.warmup()).resolves.not.toThrow();
expect(spawnCli).toHaveBeenCalled(); expect(spawnCli).toHaveBeenCalled();
}); });
}); });
describe('team launch notifications', () => { describe('team launch notifications', () => {
@ -1643,7 +1697,8 @@ describe('TeamProvisioningService', () => {
], ],
})), })),
}; };
const processRows = createDeferred<Awaited<ReturnType<typeof listRuntimeProcessesForCurrentTmuxPlatform>>>(); const processRows =
createDeferred<Awaited<ReturnType<typeof listRuntimeProcessesForCurrentTmuxPlatform>>>();
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform) vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform)
.mockReturnValueOnce(processRows.promise) .mockReturnValueOnce(processRows.promise)
.mockResolvedValueOnce([]); .mockResolvedValueOnce([]);
@ -1720,7 +1775,8 @@ describe('TeamProvisioningService', () => {
})), })),
}; };
(svc as any).provisioningRunByTeam.set('runtime-team', 'run-1'); (svc as any).provisioningRunByTeam.set('runtime-team', 'run-1');
const processRows = createDeferred<Awaited<ReturnType<typeof listRuntimeProcessesForCurrentTmuxPlatform>>>(); const processRows =
createDeferred<Awaited<ReturnType<typeof listRuntimeProcessesForCurrentTmuxPlatform>>>();
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform) vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform)
.mockReturnValueOnce(processRows.promise) .mockReturnValueOnce(processRows.promise)
.mockResolvedValueOnce([]); .mockResolvedValueOnce([]);
@ -4885,10 +4941,7 @@ describe('TeamProvisioningService', () => {
}; };
await expect( await expect(
(svc as any).resolveOpenCodeMembersForRuntimeLane( (svc as any).resolveOpenCodeMembersForRuntimeLane('team-a', 'secondary:opencode:bob')
'team-a',
'secondary:opencode:bob'
)
).resolves.toEqual(['bob']); ).resolves.toEqual(['bob']);
expect(getConfigSnapshot).toHaveBeenCalledTimes(1); expect(getConfigSnapshot).toHaveBeenCalledTimes(1);
@ -5098,13 +5151,16 @@ describe('TeamProvisioningService', () => {
}) })
).resolves.toMatchObject({ ).resolves.toMatchObject({
delivered: true, delivered: true,
responsePending: false, responsePending: false,
responseState: 'responded_plain_text', responseState: 'responded_plain_text',
visibleReplyCorrelation: 'plain_assistant_text', visibleReplyCorrelation: 'plain_assistant_text',
}); });
const userInbox = JSON.parse( const userInbox = JSON.parse(
await fsPromises.readFile(path.join(tempTeamsBase, 'team-a', 'inboxes', 'user.json'), 'utf8') await fsPromises.readFile(
path.join(tempTeamsBase, 'team-a', 'inboxes', 'user.json'),
'utf8'
)
) as Array<Record<string, unknown>>; ) as Array<Record<string, unknown>>;
expect(userInbox).toHaveLength(1); expect(userInbox).toHaveLength(1);
expect(userInbox[0]).toMatchObject({ expect(userInbox[0]).toMatchObject({
@ -6465,6 +6521,107 @@ describe('TeamProvisioningService', () => {
}); });
}); });
it('accepts member work sync report as OpenCode delivery response proof', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
prePromptCursor: 'cursor-before',
responseObservation: {
state: 'responded_non_visible_tool' as const,
deliveredUserMessageId: 'oc-user-work-sync',
assistantMessageId: 'oc-assistant-work-sync-report',
toolCallNames: ['member_work_sync_status', 'member_work_sync_report'],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: null,
},
diagnostics: [],
}));
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'Work sync check for #task-1.',
messageId: 'msg-work-sync-report',
replyRecipient: 'team-lead',
actionMode: 'do',
messageKind: 'member_work_sync_nudge',
taskRefs: [
{
taskId: 'task-1',
displayId: 'task-1',
teamName: 'team-a',
},
],
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: false,
responseState: 'responded_non_visible_tool',
ledgerStatus: 'responded',
});
});
it('keeps member work sync status-only OpenCode deliveries pending', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
prePromptCursor: 'cursor-before',
responseObservation: {
state: 'responded_non_visible_tool' as const,
deliveredUserMessageId: 'oc-user-work-sync-status',
assistantMessageId: 'oc-assistant-work-sync-status',
toolCallNames: ['member_work_sync_status'],
visibleMessageToolCallId: null,
visibleReplyMessageId: null,
visibleReplyCorrelation: null,
latestAssistantPreview: null,
reason: null,
},
diagnostics: [],
}));
await configureOpenCodeBobDeliveryService({ svc, sendMessageToMember });
await expect(
svc.deliverOpenCodeMemberMessage('team-a', {
memberName: 'bob',
text: 'Work sync check for #task-1.',
messageId: 'msg-work-sync-status-only',
replyRecipient: 'team-lead',
actionMode: 'do',
messageKind: 'member_work_sync_nudge',
taskRefs: [
{
taskId: 'task-1',
displayId: 'task-1',
teamName: 'team-a',
},
],
source: 'watcher',
inboxTimestamp: '2026-04-25T10:00:00.000Z',
})
).resolves.toMatchObject({
delivered: true,
accepted: true,
responsePending: true,
responseState: 'responded_non_visible_tool',
ledgerStatus: 'retry_scheduled',
reason: 'non_visible_tool_without_task_progress',
});
});
it('marks OpenCode delivery terminal after max attempts instead of leaving it pending', async () => { it('marks OpenCode delivery terminal after max attempts instead of leaving it pending', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const emptyResponseObservation = { const emptyResponseObservation = {
@ -6968,6 +7125,107 @@ describe('TeamProvisioningService', () => {
); );
}); });
it('prefers live secondary lane runId over the primary tracked runId for OpenCode member delivery', async () => {
const svc = new TeamProvisioningService();
const teamName = 'team-a';
const laneId = 'secondary:opencode:bob';
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
ok: true,
providerId: 'opencode',
memberName: String(input.memberName),
sessionId: 'oc-session-bob',
diagnostics: [],
}));
svc.setRuntimeAdapterRegistry(
new TeamRuntimeAdapterRegistry([
{
providerId: 'opencode',
prepare: vi.fn(),
launch: vi.fn(),
reconcile: vi.fn(),
stop: vi.fn(),
sendMessageToMember,
} as any,
])
);
(svc as any).aliveRunByTeam.set(teamName, 'primary-run');
(svc as any).runs.set('primary-run', {
runId: 'primary-run',
teamName,
processKilled: false,
cancelRequested: false,
progress: { state: 'ready' },
request: { providerId: 'codex', cwd: '/repo' },
mixedSecondaryLanes: [
{
laneId,
providerId: 'opencode',
member: { name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
runId: 'opencode-run-live',
state: 'finished',
result: {
members: {
bob: {
bootstrapConfirmed: true,
launchState: 'confirmed_alive',
sessionId: 'oc-session-bob',
},
},
},
warnings: [],
diagnostics: [],
},
],
});
(svc as any).configReader = {
getConfig: vi.fn(async () => ({
projectPath: '/repo',
members: [
{ name: 'team-lead', providerId: 'codex', model: 'gpt-5.4' },
{ name: 'bob', providerId: 'opencode', model: 'minimax-m2.5-free' },
],
})),
};
(svc as any).teamMetaStore = {
getMeta: vi.fn(async () => ({
launchIdentity: { providerId: 'codex' },
providerId: 'codex',
})),
};
(svc as any).membersMetaStore = {
getMembers: vi.fn(async () => [
{
name: 'bob',
providerId: 'opencode',
model: 'opencode/minimax-m2.5-free',
},
]),
};
await expect(
svc.deliverOpenCodeMemberMessage(teamName, {
memberName: 'bob',
text: 'hello live lane',
messageId: 'msg-live-lane',
})
).resolves.toMatchObject({
delivered: true,
diagnostics: [],
});
expect(sendMessageToMember).toHaveBeenCalledWith(
expect.objectContaining({
runId: 'opencode-run-live',
teamName,
laneId,
memberName: 'bob',
})
);
expect(sendMessageToMember).not.toHaveBeenCalledWith(
expect.objectContaining({ runId: 'primary-run' })
);
});
it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => { it('blocks OpenCode secondary delivery when runtime session exists but bootstrap did not check in', async () => {
const svc = new TeamProvisioningService(); const svc = new TeamProvisioningService();
const teamName = 'team-a'; const teamName = 'team-a';
@ -7448,7 +7706,9 @@ describe('TeamProvisioningService', () => {
activeRunId: launchInput?.runId, activeRunId: launchInput?.runId,
highWatermark: 0, highWatermark: 0,
}); });
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ await expect(
readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)
).resolves.toMatchObject({
lanes: { lanes: {
'secondary:opencode:bob': { 'secondary:opencode:bob': {
state: 'degraded', state: 'degraded',
@ -7551,7 +7811,9 @@ describe('TeamProvisioningService', () => {
await (svc as any).launchMixedSecondaryLaneIfNeeded(run); await (svc as any).launchMixedSecondaryLaneIfNeeded(run);
await vi.waitFor( await vi.waitFor(
async () => { async () => {
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ await expect(
readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)
).resolves.toMatchObject({
lanes: { lanes: {
'secondary:opencode:tom': { 'secondary:opencode:tom': {
state: 'degraded', state: 'degraded',
@ -7671,7 +7933,9 @@ describe('TeamProvisioningService', () => {
).resolves.toMatchObject({ ).resolves.toMatchObject({
activeRunId: launchInput?.runId, activeRunId: launchInput?.runId,
}); });
await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ await expect(
readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)
).resolves.toMatchObject({
lanes: { lanes: {
'secondary:opencode:tom': { 'secondary:opencode:tom': {
state: 'active', state: 'active',
@ -12351,6 +12615,149 @@ describe('TeamProvisioningService', () => {
}); });
}); });
it('heals terminal bootstrap-state failures when runtime proof confirms member_briefing', async () => {
allowConsoleLogs();
const teamName = 'zz-unit-bootstrap-state-runtime-proof-heals';
const leadSessionId = 'lead-session';
const projectPath = '/Users/test/proj';
const acceptedAt = new Date(Date.now() - 90_000).toISOString();
const proofAt = new Date(Date.now() - 60_000).toISOString();
const failureAt = new Date(Date.now() - 30_000).toISOString();
const proofToken = 'proof-token-jack';
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
members: Array<Record<string, unknown>>;
};
config.members = config.members.map((member) =>
member.name === 'jack'
? {
...member,
agentId: `jack@${teamName}`,
bootstrapExpectedAfter: acceptedAt,
bootstrapProofToken: proofToken,
bootstrapRuntimeEventsPath: runtimeEventsPath,
}
: member
);
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
writeBootstrapState(
teamName,
[
{
name: 'jack',
status: 'failed',
lastAttemptAt: Date.parse(acceptedAt),
lastObservedAt: Date.parse(failureAt),
failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.',
},
],
failureAt
);
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
fs.writeFileSync(
runtimeEventsPath,
`${JSON.stringify({
version: 1,
type: 'bootstrap_confirmed',
timestamp: proofAt,
pid: 1234,
teamName,
agentName: 'jack',
agentId: `jack@${teamName}`,
source: 'member_briefing_tool_success',
bootstrapProofToken: proofToken,
})}\n`,
'utf8'
);
const svc = new TeamProvisioningService();
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.teamLaunchState).toBe('clean_success');
expect(result.statuses.jack).toMatchObject({
status: 'online',
launchState: 'confirmed_alive',
bootstrapConfirmed: true,
runtimeAlive: true,
hardFailure: false,
error: undefined,
});
});
it('does not heal bootstrap-state failures from stale runtime proof before spawn acceptance', async () => {
allowConsoleLogs();
const teamName = 'zz-unit-bootstrap-state-stale-runtime-proof-ignored';
const leadSessionId = 'lead-session';
const projectPath = '/Users/test/proj';
const proofAt = new Date(Date.now() - 120_000).toISOString();
const acceptedAt = new Date(Date.now() - 90_000).toISOString();
const failureAt = new Date(Date.now() - 30_000).toISOString();
const proofToken = 'proof-token-jack';
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'jack.runtime.jsonl');
writeLaunchConfig(teamName, projectPath, leadSessionId, ['jack']);
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
members: Array<Record<string, unknown>>;
};
config.members = config.members.map((member) =>
member.name === 'jack'
? {
...member,
agentId: `jack@${teamName}`,
bootstrapExpectedAfter: acceptedAt,
bootstrapProofToken: proofToken,
bootstrapRuntimeEventsPath: runtimeEventsPath,
}
: member
);
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
writeBootstrapState(
teamName,
[
{
name: 'jack',
status: 'failed',
lastAttemptAt: Date.parse(acceptedAt),
lastObservedAt: Date.parse(failureAt),
failureReason: 'Teammate was registered but did not bootstrap-confirm before timeout.',
},
],
failureAt
);
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
fs.writeFileSync(
runtimeEventsPath,
`${JSON.stringify({
version: 1,
type: 'bootstrap_confirmed',
timestamp: proofAt,
pid: 1234,
teamName,
agentName: 'jack',
agentId: `jack@${teamName}`,
source: 'member_briefing_tool_success',
bootstrapProofToken: proofToken,
})}\n`,
'utf8'
);
const svc = new TeamProvisioningService();
const result = await svc.getMemberSpawnStatuses(teamName);
expect(result.teamLaunchState).toBe('partial_failure');
expect(result.statuses.jack).toMatchObject({
status: 'error',
launchState: 'failed_to_start',
bootstrapConfirmed: false,
runtimeAlive: false,
hardFailure: true,
});
});
it('does not heal bootstrap-state failures from stale pre-launch transcript success', async () => { it('does not heal bootstrap-state failures from stale pre-launch transcript success', async () => {
allowConsoleLogs(); allowConsoleLogs();
const teamName = 'zz-unit-bootstrap-state-stale-transcript-ignored'; const teamName = 'zz-unit-bootstrap-state-stale-transcript-ignored';
@ -13258,7 +13665,9 @@ describe('TeamProvisioningService', () => {
teamName, teamName,
laneId, laneId,
state: 'active', state: 'active',
diagnostics: ['OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.'], diagnostics: [
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.',
],
}); });
await writeCommittedOpenCodeSessionStore({ await writeCommittedOpenCodeSessionStore({
teamName, teamName,
@ -15190,11 +15599,11 @@ describe('TeamProvisioningService', () => {
bootstrapConfirmed: true, bootstrapConfirmed: true,
hardFailure: false, hardFailure: false,
}); });
await expect(fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')).rejects.toMatchObject( await expect(
{ fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
code: 'ENOENT', ).rejects.toMatchObject({
} code: 'ENOENT',
); });
await expect( await expect(
fsPromises.readFile(getTeamLaunchSummaryPath(teamName), 'utf8') fsPromises.readFile(getTeamLaunchSummaryPath(teamName), 'utf8')
).rejects.toMatchObject({ ).rejects.toMatchObject({
@ -16761,7 +17170,9 @@ describe('TeamProvisioningService', () => {
launchState: 'failed_to_start', launchState: 'failed_to_start',
hardFailureReason: 'Tom provider launch failed.', hardFailureReason: 'Tom provider launch failed.',
}); });
const summary = JSON.parse(await fsPromises.readFile(getTeamLaunchSummaryPath(teamName), 'utf8')); const summary = JSON.parse(
await fsPromises.readFile(getTeamLaunchSummaryPath(teamName), 'utf8')
);
expect(summary).toMatchObject({ expect(summary).toMatchObject({
teamLaunchState: 'partial_failure', teamLaunchState: 'partial_failure',
confirmedCount: 2, confirmedCount: 2,
@ -16853,7 +17264,9 @@ describe('TeamProvisioningService', () => {
launchState: 'failed_to_start', launchState: 'failed_to_start',
hardFailureReason: exactOpenCodeReason, hardFailureReason: exactOpenCodeReason,
}); });
const persisted = JSON.parse(await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')); const persisted = JSON.parse(
await fsPromises.readFile(getTeamLaunchStatePath(teamName), 'utf8')
);
expect(persisted.members.alice).toMatchObject({ expect(persisted.members.alice).toMatchObject({
laneId: 'secondary:opencode:alice', laneId: 'secondary:opencode:alice',
launchState: 'failed_to_start', launchState: 'failed_to_start',

View file

@ -306,6 +306,48 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1); expect(service.getLiveLeadProcessMessages(teamName)).toHaveLength(1);
}); });
it('treats member work sync nudges as actionable in lead relay prompt', async () => {
const service = new TeamProvisioningService();
const teamName = 'my-team';
service.setControlApiBaseUrlResolver(async () => 'http://127.0.0.1:43123');
seedConfig(teamName);
seedLeadInbox(teamName, [
{
from: 'system',
text: 'Work sync check: you have current actionable work assigned.',
timestamp: '2026-02-23T10:00:00.000Z',
read: false,
summary: 'Work sync check',
messageId: 'm-work-sync-1',
source: 'system_notification',
messageKind: 'member_work_sync_nudge',
taskRefs: [{ teamName, taskId: 'task-1', displayId: '11111111' }],
},
]);
const { writeSpy } = attachAliveRun(service, teamName);
const relayPromise = service.relayLeadInboxMessages(teamName);
const run = await waitForCapture(service);
expect(run?.leadRelayCapture).toBeTruthy();
const payload = String(writeSpy.mock.calls[0]?.[0] ?? '');
expect(payload).toContain('Message kind: member_work_sync_nudge');
expect(payload).toContain('it is actionable work-sync control traffic');
expect(payload).toContain(
'Call member_work_sync_status with teamName=\\"my-team\\", memberName=\\"team-lead\\", controlUrl=\\"http://127.0.0.1:43123\\"'
);
expect(payload).toContain('call member_work_sync_report');
expect(payload).toContain('controlUrl=\\"http://127.0.0.1:43123\\"');
expect(payload).toContain('taskIds from the nudge task refs');
expect(payload).toContain(
'Do not use provider names, runtime names, or team names as memberName'
);
expect(payload).toContain('Do NOT ignore it as a pure system notification');
(service as any).handleStreamJsonMessage(run, { type: 'result', subtype: 'success' });
await expect(relayPromise).resolves.toBe(1);
});
it('uses snapshot config reads for lead inbox relay routing', async () => { it('uses snapshot config reads for lead inbox relay routing', async () => {
const getConfig = vi.fn(async () => { const getConfig = vi.fn(async () => {
throw new Error('verified config read should not be used for inbox relay routing'); throw new Error('verified config read should not be used for inbox relay routing');
@ -2269,9 +2311,7 @@ describe('TeamProvisioningService relayLeadInboxMessages', () => {
expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 }); expect(relay).toMatchObject({ kind: 'opencode_member', relayed: 1 });
expect(recipientSpy).toHaveBeenCalledTimes(1); expect(recipientSpy).toHaveBeenCalledTimes(1);
const rows = JSON.parse( const rows = JSON.parse(hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]');
hoisted.files.get(`/mock/teams/${teamName}/inboxes/jack.json`) ?? '[]'
);
expect(rows[0].read).toBe(true); expect(rows[0].read).toBe(true);
}); });

View file

@ -40,6 +40,21 @@ export async function startMemberWorkSyncControlServer(
sendJson(response, 200, payload); sendJson(response, 200, payload);
return; return;
} }
if (
request.method === 'POST' &&
parts.length === 6 &&
parts[0] === 'api' &&
parts[1] === 'teams' &&
parts[3] === 'member-work-sync' &&
parts[5] === 'refresh'
) {
const payload = await feature.refreshStatus({
teamName: parts[2],
memberName: parts[4],
});
sendJson(response, 200, payload);
return;
}
if ( if (
request.method === 'POST' && request.method === 'POST' &&
parts.length === 5 && parts.length === 5 &&
@ -152,9 +167,7 @@ export async function formatMemberWorkSyncDiagnostics(input: {
input.feature.getMetrics({ teamName: input.teamName }), input.feature.getMetrics({ teamName: input.teamName }),
input.taskId ? new TeamTaskReader().getTasks(input.teamName) : Promise.resolve([]), input.taskId ? new TeamTaskReader().getTasks(input.teamName) : Promise.resolve([]),
]); ]);
const task = input.taskId const task = input.taskId ? tasks.find((candidate) => candidate.id === input.taskId) : undefined;
? tasks.find((candidate) => candidate.id === input.taskId)
: undefined;
return [ return [
'Member work sync live diagnostics:', 'Member work sync live diagnostics:',
JSON.stringify( JSON.stringify(
@ -240,12 +253,7 @@ export async function readRuntimeTurnSettledProcessedMetas(teamsBasePath: string
meta: Record<string, unknown>; meta: Record<string, unknown>;
}> }>
> { > {
const processedDir = path.join( const processedDir = path.join(teamsBasePath, '.member-work-sync', 'runtime-hooks', 'processed');
teamsBasePath,
'.member-work-sync',
'runtime-hooks',
'processed'
);
const entries = await fs.readdir(processedDir, { withFileTypes: true }).catch(() => []); const entries = await fs.readdir(processedDir, { withFileTypes: true }).catch(() => []);
const metas = await Promise.all( const metas = await Promise.all(
entries entries

View file

@ -29,12 +29,16 @@ describe('HttpAPIClient memberWorkSync', () => {
const client = new HttpAPIClient('http://127.0.0.1:53123'); const client = new HttpAPIClient('http://127.0.0.1:53123');
fetchMock fetchMock
.mockResolvedValueOnce(jsonResponse({ state: 'needs_sync' })) .mockResolvedValueOnce(jsonResponse({ state: 'needs_sync' }))
.mockResolvedValueOnce(jsonResponse({ state: 'still_working' }))
.mockResolvedValueOnce(jsonResponse({ memberCount: 1 })) .mockResolvedValueOnce(jsonResponse({ memberCount: 1 }))
.mockResolvedValueOnce(jsonResponse({ accepted: true })); .mockResolvedValueOnce(jsonResponse({ accepted: true }));
await expect( await expect(
client.memberWorkSync.getStatus({ teamName: 'demo team', memberName: 'bob/qa' }) client.memberWorkSync.getStatus({ teamName: 'demo team', memberName: 'bob/qa' })
).resolves.toEqual({ state: 'needs_sync' }); ).resolves.toEqual({ state: 'needs_sync' });
await expect(
client.memberWorkSync.refreshStatus({ teamName: 'demo team', memberName: 'bob/qa' })
).resolves.toEqual({ state: 'still_working' });
await expect(client.memberWorkSync.getMetrics({ teamName: 'demo team' })).resolves.toEqual({ await expect(client.memberWorkSync.getMetrics({ teamName: 'demo team' })).resolves.toEqual({
memberCount: 1, memberCount: 1,
}); });
@ -57,11 +61,21 @@ describe('HttpAPIClient memberWorkSync', () => {
); );
expect(fetchMock).toHaveBeenNthCalledWith( expect(fetchMock).toHaveBeenNthCalledWith(
2, 2,
'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/bob%2Fqa/refresh',
expect.objectContaining({
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({}),
signal: expect.any(AbortSignal),
})
);
expect(fetchMock).toHaveBeenNthCalledWith(
3,
'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/metrics', 'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/metrics',
expect.objectContaining({ signal: expect.any(AbortSignal) }) expect.objectContaining({ signal: expect.any(AbortSignal) })
); );
expect(fetchMock).toHaveBeenNthCalledWith( expect(fetchMock).toHaveBeenNthCalledWith(
3, 4,
'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/report', 'http://127.0.0.1:53123/api/teams/demo%20team/member-work-sync/report',
expect.objectContaining({ expect.objectContaining({
method: 'POST', method: 'POST',

View file

@ -95,11 +95,13 @@ describe('ClaudeLogsPanel', () => {
await Promise.resolve(); await Promise.resolve();
}); });
expect(host.textContent).toContain('2 lines'); expect(host.textContent).toContain('2 raw lines');
expect(host.textContent).toContain('first line'); expect(host.textContent).toContain('first line');
expect(host.textContent).not.toContain('Team is not running.'); expect(host.textContent).not.toContain('Team is not running.');
expect(host.querySelector('[data-testid="cli-logs-rich-view"]')).not.toBeNull(); expect(host.querySelector('[data-testid="cli-logs-rich-view"]')).not.toBeNull();
expect(cliLogsRichViewState.calls.at(-1)?.cliLogsTail).toBe('[stdout]\nfirst line\nsecond line'); expect(cliLogsRichViewState.calls.at(-1)?.cliLogsTail).toBe(
'[stdout]\nfirst line\nsecond line'
);
await act(async () => { await act(async () => {
root.unmount(); root.unmount();

View file

@ -158,7 +158,7 @@ describe('buildTeamRuntimeDisplayRows', () => {
memberName: 'alice', memberName: 'alice',
state: 'degraded', state: 'degraded',
source: 'mixed', source: 'mixed',
stateReason: 'Bootstrap command failed', stateReason: 'Bootstrap command failed. Process is still alive.',
actionsAllowed: false, actionsAllowed: false,
}); });
}); });

View file

@ -27,4 +27,29 @@ describe('openCodeRuntimeDeliveryDiagnostics', () => {
reason: 'empty_assistant_turn', reason: 'empty_assistant_turn',
}); });
}); });
it('surfaces prompt delivery with no recorded assistant turn separately', () => {
const diagnostics = buildOpenCodeRuntimeDeliveryDiagnostics({
deliveredToInbox: true,
messageId: 'msg-no-assistant',
runtimeDelivery: {
providerId: 'opencode',
attempted: true,
delivered: false,
responsePending: false,
responseState: 'prompt_delivered_no_assistant_message',
ledgerStatus: 'failed_terminal',
reason: 'prompt_delivered_no_assistant_message',
diagnostics: ['prompt_delivered_no_assistant_message'],
},
});
expect(diagnostics.warning).toBe(
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode accepted the prompt, but no assistant turn was recorded.'
);
expect(diagnostics.debugDetails).toMatchObject({
responseState: 'prompt_delivered_no_assistant_message',
reason: 'prompt_delivered_no_assistant_message',
});
});
}); });

View file

@ -17,7 +17,10 @@ describe('parseStreamJsonToGroups', () => {
expect(groups).toHaveLength(1); expect(groups).toHaveLength(1);
expect(groups[0]?.items).toEqual( expect(groups[0]?.items).toEqual(
expect.arrayContaining([ expect.arrayContaining([
expect.objectContaining({ type: 'output', content: 'Codex native thread started: thread-1.' }), expect.objectContaining({
type: 'output',
content: 'Codex native thread started: thread-1.',
}),
expect.objectContaining({ type: 'output', content: 'Codex turn started.' }), expect.objectContaining({ type: 'output', content: 'Codex turn started.' }),
expect.objectContaining({ type: 'output', content: 'Lead response ready.' }), expect.objectContaining({ type: 'output', content: 'Lead response ready.' }),
expect.objectContaining({ expect.objectContaining({
@ -53,6 +56,46 @@ describe('parseStreamJsonToGroups', () => {
}); });
}); });
it('renders Codex native command execution and file change events from live JSONL logs', () => {
const groups = parseStreamJsonToGroups(
[
'{"type":"item.started","item":{"id":"item_1","type":"command_execution","command":"pwd","status":"in_progress"}}',
'{"type":"item.completed","item":{"id":"item_1","type":"command_execution","command":"pwd","aggregated_output":"/repo\\n","exit_code":0,"status":"completed"}}',
'{"type":"item.completed","item":{"id":"item_2","type":"file_change","changes":[{"path":"/repo/src/a.ts","kind":"update"}],"status":"completed"}}',
].join('\n')
);
const tools = groups.flatMap((group) => group.items).filter((item) => item.type === 'tool');
expect(tools).toHaveLength(2);
expect(tools[0]).toMatchObject({
type: 'tool',
tool: {
id: 'item_1',
name: 'Bash',
input: { command: 'pwd' },
isOrphaned: false,
result: {
content: '/repo\n',
isError: false,
},
},
});
expect(tools[1]).toMatchObject({
type: 'tool',
tool: {
id: 'item_2',
name: 'Edit',
input: { file_path: '/repo/src/a.ts' },
isOrphaned: false,
result: {
content: 'File changes:\n- /repo/src/a.ts (update)',
isError: false,
},
},
});
});
it('renders projected Codex native system status rows from persisted logs', () => { it('renders projected Codex native system status rows from persisted logs', () => {
const groups = parseStreamJsonToGroups( const groups = parseStreamJsonToGroups(
[ [