diff --git a/README.md b/README.md index cda15700..f717c2de 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@

- Latest Release  + Latest Release  CI Status  Discord

diff --git a/agent-teams-controller/src/internal/context.js b/agent-teams-controller/src/internal/context.js index abe218e1..e9dec826 100644 --- a/agent-teams-controller/src/internal/context.js +++ b/agent-teams-controller/src/internal/context.js @@ -16,6 +16,7 @@ function createControllerContext(options = {}) { teamName, claudeDir: paths.claudeDir, paths, + allowUserMessageSender: options.allowUserMessageSender !== false, }; } diff --git a/agent-teams-controller/src/internal/messages.js b/agent-teams-controller/src/internal/messages.js index 357feb7f..7a678404 100644 --- a/agent-teams-controller/src/internal/messages.js +++ b/agent-teams-controller/src/internal/messages.js @@ -158,15 +158,22 @@ function normalizeMessageSendFlags(context, flags) { next.member = resolvedTo; } + const fromRequiredForAgentTool = context.allowUserMessageSender === false; if (typeof next.from === 'string' && next.from.trim()) { const rawFrom = next.from.trim(); if (rawFrom.toLowerCase() !== 'user') { next.from = runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFrom, 'from', { allowLeadAliases: true, }); + } else if (fromRequiredForAgentTool) { + throw new Error( + 'message_send from user is reserved for the human user. Set from to your configured teammate name.' + ); } else { next.from = 'user'; } + } else if (fromRequiredForAgentTool) { + throw new Error('message_send requires from to be your configured teammate name.'); } return next; diff --git a/agent-teams-controller/src/internal/runtimeHelpers.js b/agent-teams-controller/src/internal/runtimeHelpers.js index 91003302..93784df0 100644 --- a/agent-teams-controller/src/internal/runtimeHelpers.js +++ b/agent-teams-controller/src/internal/runtimeHelpers.js @@ -225,6 +225,85 @@ function resolveExplicitTeamMemberName(paths, candidate, options = {}) { return null; } +function getLeadProviderKeys(paths, explicitMembers) { + const leadName = inferLeadName(paths); + const leadKey = normalizeMemberKey(leadName); + const leadMember = + (leadKey ? explicitMembers.membersByKey.get(leadKey) : null) || + Array.from(explicitMembers.membersByKey.values()).find((member) => isCanonicalLeadMember(member)); + if (!leadMember) return { leadName: '', keys: new Set() }; + + const keys = new Set(); + for (const field of ['providerId', 'provider', 'providerBackendId']) { + const key = normalizeMemberKey(leadMember[field]); + if (key) keys.add(key); + } + return { leadName: leadMember.name, keys }; +} + +function formatAllowedTaskCommentAuthors(paths, explicitMembers, options = {}) { + const allowed = new Set(); + if (options.allowReservedAuthors !== false) { + allowed.add('user'); + allowed.add('system'); + } + for (const member of explicitMembers.membersByKey.values()) { + if (member && typeof member.name === 'string' && member.name.trim()) { + allowed.add(member.name.trim()); + } + } + + const leadName = inferLeadName(paths); + const leadKey = normalizeMemberKey(leadName); + if (leadKey && explicitMembers.membersByKey.has(leadKey)) { + allowed.add('lead'); + allowed.add('team-lead'); + } + + return Array.from(allowed).sort((a, b) => a.localeCompare(b)).join(', '); +} + +function resolveTaskCommentAuthorName(paths, candidate, label = 'task comment author', options = {}) { + const normalized = typeof candidate === 'string' && candidate.trim() ? candidate.trim() : ''; + if (!normalized) { + return inferLeadName(paths); + } + + const key = normalizeMemberKey(normalized); + if (key === 'user' || key === 'system') { + if (options.allowReservedAuthors === false) { + throw new Error( + `${label} "${key}" is reserved for app-owned writes. Set from to your configured teammate name.` + ); + } + return key; + } + + const explicit = collectExplicitTeamMembers(paths); + const directMember = explicit.membersByKey.get(key); + if (directMember && !explicit.removedNames.has(key)) { + return directMember.name; + } + + const leadAlias = resolveExplicitTeamMemberName(paths, normalized, { allowLeadAliases: true }); + if (leadAlias) { + return leadAlias; + } + + const { leadName, keys } = getLeadProviderKeys(paths, explicit); + if (leadName && keys.has(key)) { + return leadName; + } + + throw new Error( + `Unknown ${label}: ${normalized}. Use one of: ${formatAllowedTaskCommentAuthors( + paths, + explicit, + options + )}.` + ); +} + function assertExplicitTeamMemberName(paths, candidate, label = 'member', options = {}) { const resolved = resolveExplicitTeamMemberName(paths, candidate, options); if (!resolved) { @@ -626,6 +705,7 @@ module.exports = { readMembersMeta, readTeamConfig, resolveExplicitTeamMemberName, + resolveTaskCommentAuthorName, resolveTeamMembers, getCurrentRuntimeMemberIdentity, resolveCanonicalLeadSessionId, diff --git a/agent-teams-controller/src/internal/tasks.js b/agent-teams-controller/src/internal/tasks.js index 5e05992f..13dea345 100644 --- a/agent-teams-controller/src/internal/tasks.js +++ b/agent-teams-controller/src/internal/tasks.js @@ -229,14 +229,16 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) { } const leadName = runtimeHelpers.inferLeadName(context.paths); - if (isSameTaskMember(owner, comment.author, leadName)) { + const rawAuthor = normalizeActorName(comment.author); + const sender = rawAuthor.toLowerCase() === 'system' ? leadName : rawAuthor || leadName; + if (isSameTaskMember(owner, sender, leadName)) { return; } const leadSessionId = runtimeHelpers.resolveLeadSessionId(context.paths); messages.sendMessage(context, { member: owner, - from: normalizeActorName(comment.author) || leadName, + from: sender, text: buildCommentNotificationMessage(context, task, comment), taskRefs: Array.isArray(comment.taskRefs) ? comment.taskRefs : undefined, summary: `Comment on #${task.displayId || task.id}`, @@ -410,11 +412,16 @@ function notifyUnblockedOwners(context, completedTask) { // Stable comment ID prevents duplicates when completeTask is called // multiple times for the same task (e.g. agent retry). addTaskComment // in taskStore.js deduplicates by id (line 485). - addTaskComment(context, blockedTask.id, { - id: `dep-resolved-${completedTask.id}-${blockedTask.id}`, - text: lines.join('\n'), - from: 'system', - }); + addTaskCommentWithOptions( + context, + blockedTask.id, + { + id: `dep-resolved-${completedTask.id}-${blockedTask.id}`, + text: lines.join('\n'), + from: 'system', + }, + { trustedInternalWrite: true } + ); } catch { // Best-effort per blocked task: skip on failure } @@ -497,23 +504,37 @@ function updateTaskFields(context, taskId, fields) { ); } -function addTaskComment(context, taskId, flags) { +function addTaskCommentWithOptions(context, taskId, flags, options = {}) { + const commentFlags = flags || {}; + const fromRequiredForAgentTool = + context.allowUserMessageSender === false && options.trustedInternalWrite !== true; + if ( + fromRequiredForAgentTool && + !(typeof commentFlags.from === 'string' && commentFlags.from.trim()) + ) { + throw new Error('task_add_comment requires from to be your configured teammate name.'); + } + const author = runtimeHelpers.resolveTaskCommentAuthorName( + context.paths, + commentFlags.from, + 'task comment author', + { allowReservedAuthors: !fromRequiredForAgentTool } + ); const result = withTeamBoardLock(context.paths, () => - taskStore.addTaskComment(context.paths, taskId, flags.text, { - author: typeof flags.from === 'string' && flags.from.trim() ? - flags.from.trim() : runtimeHelpers.inferLeadName(context.paths), - ...(flags.id ? { id: flags.id } : {}), - ...(flags.createdAt ? { createdAt: flags.createdAt } : {}), - ...(flags.type ? { type: flags.type } : {}), - ...(Array.isArray(flags.taskRefs) ? { taskRefs: flags.taskRefs } : {}), - ...(Array.isArray(flags.attachments) ? { attachments: flags.attachments } : {}), + taskStore.addTaskComment(context.paths, taskId, commentFlags.text, { + author, + ...(commentFlags.id ? { id: commentFlags.id } : {}), + ...(commentFlags.createdAt ? { createdAt: commentFlags.createdAt } : {}), + ...(commentFlags.type ? { type: commentFlags.type } : {}), + ...(Array.isArray(commentFlags.taskRefs) ? { taskRefs: commentFlags.taskRefs } : {}), + ...(Array.isArray(commentFlags.attachments) ? { attachments: commentFlags.attachments } : {}), }) ); try { maybeNotifyTaskOwnerOnComment(context, result.task, result.comment, { inserted: result.inserted, - notifyOwner: flags.notifyOwner, + notifyOwner: commentFlags.notifyOwner, }); } catch (notifyError) { warnNonCritical(`[tasks] owner notification failed for task ${taskId}`, notifyError); @@ -529,6 +550,10 @@ function addTaskComment(context, taskId, flags) { }; } +function addTaskComment(context, taskId, flags) { + return addTaskCommentWithOptions(context, taskId, flags); +} + function attachTaskFile(context, taskId, flags) { const canonicalTaskId = resolveTaskId(context, taskId); const saved = runtimeHelpers.saveTaskAttachmentFile(context.paths, canonicalTaskId, flags); diff --git a/agent-teams-controller/test/controller.test.js b/agent-teams-controller/test/controller.test.js index e95b3655..b0494fd4 100644 --- a/agent-teams-controller/test/controller.test.js +++ b/agent-teams-controller/test/controller.test.js @@ -1080,6 +1080,52 @@ describe('agent-teams-controller API', () => { ).toThrow('message_send cannot target cross_team_send. Use cross_team_send with toTeam.'); }); + it('prevents agent-facing message_send from impersonating the human user', () => { + const claudeDir = makeClaudeDir(); + const appController = createController({ teamName: 'my-team', claudeDir }); + const agentController = createController({ + teamName: 'my-team', + claudeDir, + allowUserMessageSender: false, + }); + + const appMessage = appController.messages.sendMessage({ + to: 'team-lead', + from: 'user', + text: 'Real user question.', + summary: 'User question', + }); + expect(appMessage.deliveredToInbox).toBe(true); + + expect(() => + agentController.messages.sendMessage({ + to: 'team-lead', + from: 'user', + text: 'Forged user message.', + summary: 'Forged', + }) + ).toThrow('message_send from user is reserved for the human user'); + + expect(() => + agentController.messages.sendMessage({ + to: 'team-lead', + text: 'Missing sender should not default to user.', + }) + ).toThrow('message_send requires from to be your configured teammate name'); + + const agentMessage = agentController.messages.sendMessage({ + to: 'team-lead', + from: 'bob', + text: 'Legitimate teammate message.', + summary: 'Teammate update', + }); + expect(agentMessage.deliveredToInbox).toBe(true); + + const leadInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json'); + const leadRows = JSON.parse(fs.readFileSync(leadInboxPath, 'utf8')); + expect(leadRows.map((row) => row.from)).toEqual(['user', 'bob']); + }); + it('wakes task owner on regular comment from another member', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); @@ -1099,6 +1145,181 @@ describe('agent-teams-controller API', () => { expect(rows[0].leadSessionId).toBe('lead-session-1'); }); + it('normalizes task comment authors at the write boundary', () => { + 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 controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Review result', notifyOwner: false }); + + const fromProvider = controller.tasks.addTaskComment(task.id, { + from: 'codex', + text: 'Lead runtime finished review.', + }); + const fromAlias = controller.tasks.addTaskComment(task.id, { + from: 'lead', + text: 'Lead alias finished review.', + }); + const fromUser = controller.tasks.addTaskComment(task.id, { + from: 'User', + text: 'User follow-up.', + }); + const fromSystem = controller.tasks.addTaskComment(task.id, { + from: 'System', + text: 'System note.', + }); + + expect(fromProvider.comment.author).toBe('team-lead'); + expect(fromAlias.comment.author).toBe('team-lead'); + expect(fromUser.comment.author).toBe('user'); + expect(fromSystem.comment.author).toBe('system'); + }); + + it('does not map a real teammate named like the lead provider id to the lead', () => { + 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: 'codex', role: 'developer' }, + ], + }, + null, + 2 + ) + ); + + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Member named codex', notifyOwner: false }); + + const commented = controller.tasks.addTaskComment(task.id, { + from: 'codex', + text: 'Real teammate comment.', + }); + + expect(commented.comment.author).toBe('codex'); + }); + + it('rejects task comments from unknown authors', () => { + const claudeDir = makeClaudeDir(); + const controller = createController({ teamName: 'my-team', claudeDir }); + const task = controller.tasks.createTask({ subject: 'Reject unknown author', notifyOwner: false }); + + expect(() => + controller.tasks.addTaskComment(task.id, { + from: 'ghost', + text: 'This should not be persisted.', + }) + ).toThrow('Unknown task comment author: ghost'); + + expect(controller.tasks.getTask(task.id).comments || []).toEqual([]); + }); + + it('prevents agent-facing task_add_comment from impersonating app-owned authors', () => { + const claudeDir = makeClaudeDir(); + const appController = createController({ teamName: 'my-team', claudeDir }); + const agentController = createController({ + teamName: 'my-team', + claudeDir, + allowUserMessageSender: false, + }); + const task = appController.tasks.createTask({ subject: 'Reserved comment authors', notifyOwner: false }); + + const appComment = appController.tasks.addTaskComment(task.id, { + from: 'user', + text: 'Real user comment.', + }); + expect(appComment.comment.author).toBe('user'); + + expect(() => + agentController.tasks.addTaskComment(task.id, { + from: 'user', + text: 'Forged user comment.', + }) + ).toThrow('task comment author "user" is reserved for app-owned writes'); + + expect(() => + agentController.tasks.addTaskComment(task.id, { + text: 'Missing sender should not default to lead.', + }) + ).toThrow('task_add_comment requires from to be your configured teammate name'); + + let unknownAuthorError; + try { + agentController.tasks.addTaskComment(task.id, { + from: 'ghost', + text: 'Unknown teammate should get a useful recovery error.', + }); + } catch (error) { + unknownAuthorError = error; + } + expect(unknownAuthorError.message).toContain('Unknown task comment author: ghost'); + expect(unknownAuthorError.message).not.toContain('user'); + expect(unknownAuthorError.message).not.toContain('system'); + + const agentComment = agentController.tasks.addTaskComment(task.id, { + from: 'bob', + text: 'Legitimate teammate comment.', + }); + expect(agentComment.comment.author).toBe('bob'); + + const comments = agentController.tasks.getTask(task.id).comments || []; + expect(comments.map((comment) => comment.author)).toEqual(['user', 'bob']); + }); + + it('keeps internal dependency comments when agent-facing task_complete unblocks a task', () => { + const claudeDir = makeClaudeDir(); + const appController = createController({ teamName: 'my-team', claudeDir }); + const agentController = createController({ + teamName: 'my-team', + claudeDir, + allowUserMessageSender: false, + }); + const dependency = appController.tasks.createTask({ + subject: 'Prepare calculator API', + owner: 'bob', + notifyOwner: false, + }); + const blocked = appController.tasks.createTask({ + subject: 'Build calculator UI', + owner: 'bob', + 'blocked-by': dependency.displayId, + notifyOwner: false, + }); + + expect(() => agentController.tasks.completeTask(dependency.id, 'bob')).not.toThrow(); + + const comments = appController.tasks.getTask(blocked.id).comments || []; + expect(comments).toHaveLength(1); + expect(comments[0].author).toBe('system'); + expect(comments[0].id).toBe(`dep-resolved-${dependency.id}-${blocked.id}`); + expect(comments[0].text).toContain('Dependency resolved'); + + const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'bob.json'); + const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8')); + expect(rows).toHaveLength(1); + expect(rows[0].from).toBe('alice'); + expect(rows[0].text).toContain('Dependency resolved'); + }); + it('includes the assigned task ref in owner assignment notifications', () => { const claudeDir = makeClaudeDir(); const controller = createController({ teamName: 'my-team', claudeDir }); diff --git a/docs/research/messenger-connectors-uncertainty-pass-27.md b/docs/research/messenger-connectors-uncertainty-pass-27.md new file mode 100644 index 00000000..f55de1b9 --- /dev/null +++ b/docs/research/messenger-connectors-uncertainty-pass-27.md @@ -0,0 +1,345 @@ +# Messenger Connectors - Uncertainty Pass 27 + +Date: 2026-04-28 +Scope: remaining low-confidence areas after topic capability design +Context source: previous architecture worktree doc at `/Users/belief/dev/projects/claude/_worktrees/claude_team_messenger_connectors/docs/messenger-connectors-architecture.md` + +## Executive Delta + +The highest risk is no longer "can Telegram topics work at all". The design now has proof and fallback paths. + +The next real risk is identity and lifecycle: + +```text +Telegram topic route -> team identity -> member identity -> message identity +``` + +Current app code is mostly keyed by `teamName`. That is workable for UI, but risky for messenger routes because external provider state can outlive local team folders. + +## Source Facts Rechecked + +Telegram official docs checked on 2026-04-28: + +- `getUpdates` update ids are useful for ignoring repeated webhook or polling updates. +- Telegram stores incoming updates only until the bot receives them, and not longer than 24 hours. +- Webhooks retry on non-2xx responses. +- `User.has_topics_enabled` and `User.allows_users_to_create_topics` are returned only by `getMe`. +- Bot API 9.4 allowed bots to create topics in private chats and allowed bots to prevent users from creating/deleting topics through BotFather Mini App. +- `reply_to_message` is only for replies in the same chat and message thread. +- `external_reply` can come from another chat or forum topic and must not be used for teammate routing. +- MTProto send errors include `TOPIC_CLOSED` and `TOPIC_DELETED`; Bot API adapter should classify equivalent provider failures into typed sanitized errors. + +Local code facts checked: + +- `TeamConfig` has `name`, `description`, `color`, `members`, `projectPath`, `leadSessionId`, `deletedAt`, but no public stable `teamId`. +- `TeamChangeEvent` does not include delete, restore, permanent-delete or rename event types. +- `deleteTeam` soft-deletes by writing `deletedAt` into `config.json`. +- `restoreTeam` removes `deletedAt`. +- `permanentlyDeleteTeam` removes team and task dirs. +- Team backup has private `identityId` and writes `_backupIdentityId` into config as a backup guard, but this is not a product-level team identity. +- Many runtime paths use `teamName` as the runtime/team id. + +Sources: + +- https://core.telegram.org/bots/api +- https://core.telegram.org/bots/api-changelog +- https://core.telegram.org/method/messages.sendMessage + +## 1. Team Identity Gap + +Messenger routes must not be keyed only by `teamName`. + +Danger scenario: + +```text +1. User connects Telegram topic to teamName="frontend". +2. User permanently deletes the team. +3. User later creates a new unrelated team with the same teamName="frontend". +4. Old Telegram topic receives a message. +5. If route is keyed only by teamName, message can route to the new unrelated team. +``` + +This is worse than a normal UI cache bug because Telegram routes are external and long-lived. + +Top 3 team identity options: + +1. Add feature-owned `messengerTeamIdentityId` registry keyed by current `teamName` and backup marker if available - 🎯 8 πŸ›‘οΈ 8 🧠 5, approx 700-1400 LOC. + - Does not require changing global `TeamConfig` schema immediately. + - Gives messenger routes stable identity. + - Can reconcile with `_backupIdentityId` but does not depend on it. + +2. Promote a stable `teamId` into `TeamConfig` globally - 🎯 7 πŸ›‘οΈ 9 🧠 8, approx 1800-4000 LOC. + - Best long-term domain model. + - Larger migration blast radius because many services assume `teamName`. + +3. Keep `teamName` only and rely on tombstones - 🎯 5 πŸ›‘οΈ 6 🧠 3, approx 400-900 LOC. + - Fast. + - Still fragile when tombstones are pruned or route state is restored from backup. + +Recommendation: + +```text +Use option 1 for messenger MVP. +Design it so global TeamConfig.teamId can replace it later. +``` + +Suggested identity record: + +```ts +type MessengerTeamIdentityRecord = { + messengerTeamIdentityId: string; + currentTeamName: string; + observedDisplayName: string; + backupIdentityId?: string; + firstSeenAt: string; + lastSeenAt: string; + state: + | "active" + | "soft_deleted" + | "restored_requires_reconnect" + | "permanently_deleted" + | "name_reused_different_identity"; +}; +``` + +Route binding should store both: + +```text +teamNameSnapshot +messengerTeamIdentityId +routeGeneration +``` + +The runtime delivery adapter can still call existing services by `teamName`, but only after the identity registry confirms that the route still points to the current team folder. + +## 2. Lifecycle Hooks Need Command-Side Events + +File watcher events are not enough for messenger routes. + +Why: + +- Soft delete and restore are command intents, not just file changes. +- Permanent delete removes files before a watcher can read useful context. +- Connector cleanup must run before or during destructive operations. +- Renderer-only refresh events cannot protect background delivery. + +Required main-process lifecycle port: + +```ts +type MessengerTeamLifecyclePort = { + beforeSoftDeleteTeam(input: { teamName: string }): Promise; + afterSoftDeleteTeam(input: { teamName: string; deletedAt: string }): Promise; + beforeRestoreTeam(input: { teamName: string }): Promise; + afterRestoreTeam(input: { teamName: string }): Promise; + beforePermanentDeleteTeam(input: { teamName: string; deleteLocalConnectorPlaintext: boolean }): Promise; + afterPermanentDeleteTeam(input: { teamName: string }): Promise; + afterTeamConfigChanged(input: { teamName: string; previousDisplayName: string; nextDisplayName: string }): Promise; +}; +``` + +Top 3 integration points: + +1. Call messenger facade directly from team IPC handlers around delete/restore/updateConfig - 🎯 8 πŸ›‘οΈ 9 🧠 6, approx 900-1800 LOC. + - Strong command ordering. + - Easy to test with mocked facade. + +2. Emit richer domain events from `TeamDataService` and subscribe in messenger feature - 🎯 8 πŸ›‘οΈ 9 🧠 7, approx 1200-2500 LOC. + - Cleaner long-term. + - Wider refactor. + +3. Infer lifecycle from file watcher and config scans - 🎯 5 πŸ›‘οΈ 6 🧠 4, approx 600-1200 LOC. + - Too late for permanent delete. + - Race-prone. + +Recommendation: + +```text +Use option 1 first. +Keep the facade shape compatible with option 2 later. +``` + +## 3. Member Identity Gap + +Team members are also name-keyed. + +Risk: + +```text +1. Telegram bot sends a teammate message from "Alex". +2. User replies to that bot message later. +3. Meanwhile "Alex" was removed and a different member with same name was added. +4. Reply may route to the wrong teammate unless the message link stores member generation. +``` + +Minimum route target identity: + +```ts +type MessengerRouteTarget = + | { kind: "lead"; teamIdentityId: string; leadSessionId?: string | null } + | { + kind: "teammate"; + teamIdentityId: string; + memberNameSnapshot: string; + memberAgentIdSnapshot?: string; + memberRouteGeneration: number; + }; +``` + +Top 3 member identity strategies: + +1. Use `agentId` when present, otherwise member name plus `memberRouteGeneration` - 🎯 8 πŸ›‘οΈ 8 🧠 5, approx 700-1500 LOC. + - Fits current data. + - Avoids blocking MVP on member schema migration. + +2. Add stable `memberId` to every member and migrate roster stores - 🎯 7 πŸ›‘οΈ 9 🧠 8, approx 1800-4000 LOC. + - Best long-term. + - Larger blast radius. + +3. Use member display name only - 🎯 5 πŸ›‘οΈ 5 🧠 2, approx 200-600 LOC. + - Too weak for delayed Telegram replies. + +Recommendation: + +```text +Use option 1 in MVP. +Store target snapshots in every ProviderMessageLink. +``` + +## 4. ProviderMessageLink Must Be A Contract, Not Cache + +The link is the most important durable object in the feature. + +Recommended shape: + +```ts +type ProviderMessageLink = { + linkId: string; + provider: "telegram"; + accountBindingId: string; + routeId: string; + routeGeneration: number; + providerChatId: string; + providerThreadId: string | null; + providerMessageId: string; + internalMessageId: string; + internalMessageKind: + | "messenger_inbound" + | "lead_reply" + | "teammate_reply" + | "system_notice" + | "topic_probe"; + origin: + | "provider_user" + | "team_lead" + | "team_teammate" + | "connector_system"; + target: MessengerRouteTarget; + createdAt: string; + expiresAt?: string; +}; +``` + +Rules: + +- Never trim links only because UI messages were trimmed. +- Links for route targets should outlive `sentMessages.json`. +- Links for topic probes can have short TTL. +- Links from tombstoned routes should remain as tombstones long enough to block stale replies. + +## 5. Reply Routing Should Be Two-Phase + +Do not immediately turn a Telegram reply into a teammate message. + +Phase 1 - resolve anchor: + +```text +reply_to_message.message_id -> ProviderMessageLink +same chat id? +same thread id? +same account binding? +same route generation? +link target still valid? +``` + +Phase 2 - route message: + +```text +valid teammate target -> teammate inbox +valid lead target -> lead +missing/stale target -> lead with context +tombstoned route -> reject with reconnect notice +unknown topic -> help flow +``` + +Critical rule: + +```text +external_reply must never route to a teammate. +``` + +Bot API explicitly distinguishes same-thread `reply_to_message` from `external_reply`, so adapter normalization must preserve that distinction. + +## 6. Privacy Risk Shift + +After the no-plaintext-queue decision, the main privacy risk is not storage. It is accidental logging and diagnostic capture. + +High-risk payloads: + +```text +Telegram update JSON +callback_query data if it embeds route ids +Bot API error description if request URL/token leaks through HTTP client +message text in failed sends +team display names in topic titles +member names in projected message prefixes +``` + +Top 3 diagnostic strategies: + +1. Feature-owned sanitized diagnostic DTOs plus tests - 🎯 9 πŸ›‘οΈ 9 🧠 5, approx 700-1500 LOC. +2. Generic logger wrapper only - 🎯 6 πŸ›‘οΈ 6 🧠 4, approx 400-900 LOC. +3. Rely on "do not log raw errors" convention - 🎯 3 πŸ›‘οΈ 3 🧠 1, 0 LOC. + +Recommendation: + +```text +Use option 1. +Also add Sentry beforeSend scrubbing as defense in depth. +``` + +## 7. Current Lowest-Confidence Map + +1. Cross-client Telegram private topic UX - 🎯 5 πŸ›‘οΈ 8 🧠 6. + - Requires live probe. + - Design is resilient because of account-level confirmation and fallback. + +2. Stable local team identity for external routes - 🎯 6 πŸ›‘οΈ 8 🧠 6. + - Current app is name-keyed. + - Needs a messenger-owned identity registry before route activation. + +3. Member identity for delayed teammate replies - 🎯 6 πŸ›‘οΈ 8 🧠 6. + - Current member names can be reused. + - Store `agentId` and member generation snapshots. + +4. Lifecycle ordering on permanent delete - 🎯 7 πŸ›‘οΈ 9 🧠 6. + - Policy is clear. + - Needs command-side hook, not watcher inference. + +5. Outbound ambiguous Telegram sends - 🎯 7 πŸ›‘οΈ 9 🧠 6. + - Technical state is clear: `acceptance_unknown`. + - UX still needs concise wording. + +6. Flat menu fallback correctness - 🎯 8 πŸ›‘οΈ 8 🧠 6. + - Good fallback. + - Needs strict selection lease tests to avoid wrong-team delivery. + +## 8. Revised Next Slice + +Before building UI, implement/test these core pieces: + +1. Messenger identity registry and route generation policy - 🎯 9 πŸ›‘οΈ 9 🧠 6, approx 1000-2200 LOC. +2. ProviderMessageLink repository and reply route resolver - 🎯 9 πŸ›‘οΈ 9 🧠 6, approx 1200-2600 LOC. +3. Team lifecycle facade hooks around delete/restore/permanent delete/updateConfig - 🎯 8 πŸ›‘οΈ 9 🧠 6, approx 900-1800 LOC. +4. Telegram topic live probe fixtures - 🎯 9 πŸ›‘οΈ 9 🧠 5, approx 700-1500 LOC. + +This is the point where the design becomes robust against the bugs most likely to happen months later, not only during the happy-path onboarding demo. diff --git a/docs/research/messenger-connectors-uncertainty-pass-28.md b/docs/research/messenger-connectors-uncertainty-pass-28.md new file mode 100644 index 00000000..64cc363f --- /dev/null +++ b/docs/research/messenger-connectors-uncertainty-pass-28.md @@ -0,0 +1,528 @@ +# Messenger Connectors - Uncertainty Pass 28 + +Date: 2026-04-28 +Scope: internal delivery, reply capture, projection correctness, and loop prevention +Context source: local code in `src/main/services/team`, `src/main/ipc/teams.ts`, `src/renderer/utils/teamMessageFiltering.ts` + +## Executive Delta + +The next weakest area is the internal app boundary: + +```text +Telegram inbound -> durable local turn -> lead/teammate runtime -> user-visible reply -> Telegram outbound +``` + +The current app has several strong pieces, especially OpenCode prompt delivery ledgers, but the existing UI send path and lead inbox relay are not safe enough to reuse directly as the messenger protocol. + +New conclusion: + +```text +Build a dedicated MessengerInternalTurnLedger and MessengerReplyCollector. +Do not use renderer message feed or TeamMessageFeedService.feedRevision as the projection authority. +Do not rely on leadRelayCapture batch semantics for Telegram replies. +``` + +## Source Facts Rechecked + +Local code facts: + +- `TeamDataService.sendMessage()` delegates to the controller and invalidates message feed. It is not a messenger-specific durable state machine. +- UI direct-to-live-lead path sends stdin first, then persists best-effort through `sendDirectToLead()`. +- If stdin succeeds but persistence fails, existing UI code intentionally does not fall back to inbox because that would duplicate. +- `sendDirectToLead()` appends a `user_sent` message and returns `deliveredViaStdin: true`. +- Offline lead or teammate path writes to inbox files through `TeamInboxWriter`, which uses file locks and verifies the write. +- `relayLeadInboxMessages()` is batch-oriented, can relay up to 10 unread messages, and has an in-memory `leadRelayCapture` with a 15 second timeout. +- `relayLeadInboxMessages()` is built for lead inbox maintenance, not for exact provider-message correlation. +- `sentMessages.json` is capped at 200 messages. +- `TeamMessageFeedService` merges inbox, lead session messages, and sent messages. It dedupes, attaches session ids, links passive summaries, caches for 5 seconds and emits a `feedRevision`. +- Renderer `filterTeamMessages()` hides task comment notifications, noise, relay duplicates and other UI-only details. +- OpenCode prompt delivery already has a stronger model: ledger, response states, visible reply proof via `relayOfMessageId`, acceptance unknown and retry policy. + +Telegram facts already relevant: + +- Telegram update ids support deduping inbound updates. +- Telegram outbound `sendMessage` has no client-supplied idempotency key. +- Telegram `reply_to_message` is same chat and same thread; `external_reply` can cross chat/topic and must not drive teammate routing. + +Sources: + +- https://core.telegram.org/bots/api +- https://core.telegram.org/method/messages.sendMessage + +## 1. Existing UI Send Path Is Not The Messenger Delivery Protocol + +The UI path is optimized for responsiveness: + +```text +alive lead: + send stdin + persist user_sent best-effort + +offline lead or teammate: + write inbox + maybe relay later +``` + +For Telegram this is too weak because the provider side needs durable causality. + +Danger scenario: + +```text +1. Telegram update arrives. +2. Desktop sends to live lead stdin. +3. App crashes before persisting internal message/link. +4. Lead may answer, but connector cannot prove which Telegram message it answered. +``` + +Top 3 internal delivery options: + +1. Dedicated `MessengerInternalTurnLedger` with durable inbound-before-runtime and runtime ambiguity states - 🎯 9 πŸ›‘οΈ 9 🧠 7, approx 2000-4200 LOC. + - Correct for provider causality. + - Can reuse `TeamInboxWriter`, `TeamSentMessagesStore`, and OpenCode ledger ideas. + - More code, but isolates messenger invariants. + +2. Reuse existing `handleSendMessage`/`TeamDataService.sendMessage` and add source metadata - 🎯 5 πŸ›‘οΈ 5 🧠 3, approx 500-1200 LOC. + - Fast demo. + - Does not solve stdin-first persistence gap. + - Hard to prove reply correlation. + +3. Reuse `relayLeadInboxMessages()` as the main Telegram delivery path - 🎯 4 πŸ›‘οΈ 5 🧠 4, approx 600-1400 LOC. + - Has some capture behavior. + - Batch semantics are wrong for one Telegram turn. + - In-memory capture is not enough. + +Recommendation: + +```text +Use option 1. +Treat existing send paths as adapters, not as the messenger protocol. +``` + +## 2. Internal Runtime Delivery Also Has An Ambiguous Boundary + +Earlier we identified Telegram outbound ambiguity. The same class of bug exists inside the app: + +```text +persist send_in_flight +write prompt to live lead stdin +process/app crashes before marking runtime_delivered +``` + +After restart, the app cannot know whether the lead received the stdin prompt. + +So the internal delivery ledger needs: + +```ts +type MessengerInternalDeliveryStatus = + | "accepted_local" + | "internal_message_persisted" + | "runtime_send_pending" + | "runtime_send_in_flight" + | "runtime_delivered" + | "runtime_acceptance_unknown" + | "saved_for_later" + | "failed_terminal"; +``` + +Policy: + +- If the crash happens before runtime boundary, retry is safe. +- If the crash happens after entering `runtime_send_in_flight`, automatic retry is not always safe. +- Use deterministic `internalMessageId` and idempotency instructions, but do not pretend they are a hard exactly-once guarantee. +- For live lead stdin, stale `runtime_send_in_flight` should become `runtime_acceptance_unknown`, not automatic resend. +- For durable inbox file delivery before runtime relay, retry is safer because the inbox row has a deterministic message id. + +Top 3 policies: + +1. Mark stale live-runtime in-flight as `runtime_acceptance_unknown` and require user/recovery action - 🎯 8 πŸ›‘οΈ 9 🧠 6, approx 900-1800 LOC. + - Safest. + - Rare ambiguity can be surfaced in the connector UI. + +2. Auto-retry live-runtime in-flight with same `MessageId` and "do not duplicate" prompt - 🎯 6 πŸ›‘οΈ 6 🧠 4, approx 500-1100 LOC. + - More convenient. + - Can duplicate lead work or answers. + +3. Always write to lead inbox and never send direct stdin - 🎯 7 πŸ›‘οΈ 7 🧠 5, approx 800-1600 LOC. + - More durable source. + - Existing lead relay still ultimately crosses stdin and can duplicate after crash if unread is not marked read. + +Recommendation: + +```text +Use option 1 for live lead delivery. +Use deterministic inbox rows for offline/teammate delivery. +``` + +## 3. `user_sent` Source Is Probably Correct, But Needs Origin Ledger + +Telegram inbound from the app user is still user-originated. If the lead creates a task from that message, `task_create_from_message` should probably work. + +Therefore this is a subtle decision: + +```text +source: "user_sent" +origin ledger: provider_user / telegram route id / provider message key +``` + +Do not rely only on a new `source: "messenger_inbound"` because existing task tools accept `user_sent` as user-originated provenance. + +Top 3 source/origin options: + +1. Store Telegram inbound as `source: "user_sent"` plus durable `MessengerOriginLink` - 🎯 8 πŸ›‘οΈ 8 🧠 5, approx 700-1500 LOC. + - Preserves task provenance behavior. + - Projection can skip by origin link, not only source. + +2. Add `source: "messenger_inbound"` everywhere - 🎯 7 πŸ›‘οΈ 8 🧠 6, approx 900-2000 LOC. + - Clearer connector semantics. + - Breaks or complicates task_create_from_message eligibility unless task tools are updated. + +3. Use `source: "inbox"` for all messenger inbound - 🎯 4 πŸ›‘οΈ 5 🧠 3, approx 300-900 LOC. + - Misrepresents user-originated messages. + - Weak provenance for lead instructions and task creation. + +Recommendation: + +```text +Use option 1. +Add connector origin markers outside InboxMessage.source. +``` + +Suggested origin link: + +```ts +type MessengerOriginLink = { + internalMessageId: string; + provider: "telegram"; + accountBindingId: string; + routeId: string; + routeGeneration: number; + providerMessageKey: string; + origin: "provider_user"; + createdAt: string; +}; +``` + +Projection must check this link so user-originated Telegram messages do not echo back to Telegram. + +## 4. Lead Reply Capture Needs Single-Turn Semantics + +Existing `leadRelayCapture` is useful but not sufficient: + +- It is in-memory. +- It captures plain assistant text for a batch of lead inbox messages. +- It has no provider message key. +- It times out after 15 seconds. +- It is designed around inbox relay, not provider route proof. + +Messenger needs a single-turn collector: + +```ts +type MessengerReplyCollector = { + begin(input: { + internalTurnId: string; + teamIdentityId: string; + teamName: string; + routeId: string; + inboundInternalMessageId: string; + expectedRecipient: "user"; + startedAt: string; + timeoutMs: number; + }): Promise; + + observeInternalMessage(message: InboxMessage): Promise; + complete(input: { internalTurnId: string; reason: string }): Promise; +}; +``` + +Reply proof order: + +```text +1. SendMessage(to="user", relayOfMessageId=) +2. SendMessage(to="user") during active collector window +3. Plain lead text during active collector window, if no SendMessage was captured +4. No reply, task-only action, or tool-only action +``` + +Top 3 reply capture strategies: + +1. Dedicated single-turn collector with explicit `relayOfMessageId` preference and plain-text fallback - 🎯 8 πŸ›‘οΈ 8 🧠 7, approx 1500-3200 LOC. + - Best UX and correctness balance. + - Handles lead natural text. + - Needs careful tests around overlapping turns. + +2. Require explicit `SendMessage(to=user, relayOfMessageId=...)` for Telegram replies - 🎯 8 πŸ›‘οΈ 9 🧠 5, approx 900-1800 LOC. + - Cleaner proof. + - Lead may fail to use tool, causing "no answer" despite visible plain text. + +3. Poll `TeamMessageFeedService` for any new lead message after inbound timestamp - 🎯 5 πŸ›‘οΈ 5 🧠 4, approx 600-1400 LOC. + - Too heuristic. + - Can pick unrelated lead thoughts or another user's turn. + +Recommendation: + +```text +Use option 1. +For retries, ask explicitly for SendMessage with relayOfMessageId. +``` + +## 5. Overlapping Telegram Turns Need A Per-Team Queue + +If two Telegram messages arrive quickly in the same topic, a natural lead reply can be ambiguous. + +Top 3 concurrency models: + +1. Per-route serial queue for lead-directed Telegram turns - 🎯 8 πŸ›‘οΈ 9 🧠 6, approx 900-1800 LOC. + - Prevents plain-text reply ambiguity. + - Simple mental model. + - May delay bursts. + +2. Allow parallel turns but require explicit `relayOfMessageId` for reply correlation - 🎯 7 πŸ›‘οΈ 8 🧠 7, approx 1300-2800 LOC. + - More throughput. + - More pressure on lead/tool behavior. + +3. Free parallel processing and timestamp heuristics - 🎯 4 πŸ›‘οΈ 4 🧠 4, approx 500-1200 LOC. + - Will misroute under load. + +Recommendation: + +```text +Use option 1 for lead-directed turns in MVP. +Teammate replies can be parallel only when each target runtime has explicit delivery ledger support. +``` + +Queue key: + +```text +provider + accountBindingId + routeId + routeGeneration + targetKind +``` + +For targetKind: + +```text +lead +teammate: +``` + +## 6. Projection Must Not Use UI Feed As Authority + +`TeamMessageFeedService` is a normalized UI feed. It is useful for rendering, but not authoritative for external delivery. + +Reasons: + +- It merges different stores. +- It dedupes and prefers one copy. +- It has a 5 second cache. +- It attaches session ids heuristically. +- It computes `feedRevision` from normalized content. +- Renderer filtering hides messages for UI reasons. +- `sentMessages.json` trims to 200 messages. + +Projection to Telegram should use: + +```text +durable raw sources +ProviderMessageLink +MessengerOriginLink +MessengerProjectionLedger +Team lifecycle/identity registry +``` + +Projection source adapters: + +```ts +type MessengerProjectionSource = + | { kind: "sent_messages"; teamName: string; message: InboxMessage } + | { kind: "user_inbox"; teamName: string; message: InboxMessage } + | { kind: "runtime_delivery"; teamName: string; message: InboxMessage; journalId?: string }; +``` + +Projection eligibility: + +```text +project: + lead/team member message to user + teammate runtime delivery to user + explicit SendMessage(to=user) + +skip: + user_sent + provider-originated MessengerOriginLink + task_comment_notification + slash_command_result unless explicitly user-visible + lead thoughts with no to=user unless captured by active MessengerReplyCollector + relay duplicates with relayOfMessageId already projected + cross_team_sent unless the user asked to mirror cross-team flows later +``` + +## 7. Teammate-To-User Projection Is Real But Needs Better Attribution + +The user wanted messages from teammates to show in Telegram too. This is real, but attribution is tricky. + +Observed paths: + +- Teammate replies can land in `inboxes/user.json`. +- OpenCode runtime delivery can write user-directed messages into `sentMessages.json` with `from: envelope.fromMemberName`, `to: "user"`, `source: "lead_process"`. +- Source alone is not enough to distinguish lead vs teammate. + +Projection attribution should use: + +```text +message.from +team roster +lead name +runtime delivery journal if present +ProviderMessageLink target +memberRouteGeneration +``` + +Display prefix policy: + +```text +Lead: + "Lead: " + +Teammate: + ": " + +Unknown member: + "Team: " + attach internal diagnostics only, not visible warning +``` + +Do not create separate Telegram topics per teammate in MVP. One team topic with author prefix is still the right model. + +## 8. Loop Prevention Needs Two Ledgers + +One ledger is not enough. + +Needed: + +```text +MessengerOriginLink: + provider -> internal message + prevents echoing provider-originated user messages back to provider + +MessengerProjectionLedger: + internal message -> provider outbox + prevents sending the same internal reply multiple times +``` + +Projection ledger shape: + +```ts +type MessengerProjectionRecord = { + projectionId: string; + internalMessageId: string; + routeId: string; + routeGeneration: number; + provider: "telegram"; + status: + | "eligible" + | "skipped" + | "outbox_enqueued" + | "sent" + | "acceptance_unknown" + | "failed_terminal"; + skipReason?: string; + providerMessageKey?: string; + createdAt: string; + updatedAt: string; +}; +``` + +Critical invariant: + +```text +One internal message id can map to at most one provider outbox item per routeGeneration. +``` + +If the same message appears through live cache and durable file, the projection id must remain the same. + +## 9. Attachment And Media Should Be A Later Slice + +This is still lower confidence and should not block text MVP. + +Top 3 media strategies: + +1. Text-first MVP, summarize unsupported attachments with local notice - 🎯 9 πŸ›‘οΈ 8 🧠 3, approx 300-800 LOC. + - Avoids provider file privacy and download complexity. + +2. Telegram inbound file download into local attachment store - 🎯 7 πŸ›‘οΈ 7 🧠 7, approx 1800-3600 LOC. + - Useful. + - Needs size limits, retention, malware-safe handling, and privacy copy. + +3. Full bidirectional media sync in MVP - 🎯 5 πŸ›‘οΈ 6 🧠 9, approx 3500-7000 LOC. + - Too much scope. + +Recommendation: + +```text +Use option 1 for MVP. +Design ports so media can be added later. +``` + +## 10. Revised Internal Architecture Additions + +Add these core/application concepts: + +```text +MessengerInternalTurnLedger +MessengerInternalDeliveryPolicy +MessengerRuntimeAcceptancePolicy +MessengerReplyCollector +MessengerProjectionLedger +MessengerProjectionSourceReader +MessengerProjectionPolicy +MessengerLoopPreventionPolicy +MessengerRouteQueue +``` + +Use existing local patterns: + +- OpenCode prompt delivery ledger is the closest reference for delivery state. +- Runtime delivery journal is the closest reference for destination verification. +- `VersionedJsonStore` remains the recommended store mechanism. +- `TeamInboxWriter` is safe as an adapter for inbox persistence. + +Do not reuse directly: + +- `leadRelayCapture` as the main messenger reply collector. +- renderer `filterTeamMessages()` as projection policy. +- `feedRevision` as outbox cursor. +- `sentMessages.json` as long-term message link storage. + +## 11. Current Lowest-Confidence Map + +1. Cross-client Telegram topic UX - 🎯 5 πŸ›‘οΈ 8 🧠 6. + - Still needs live probe. + - Now isolated by capability proof and fallback. + +2. Live lead stdin acceptance ambiguity - 🎯 6 πŸ›‘οΈ 8 🧠 7. + - Newly elevated risk. + - Needs `runtime_acceptance_unknown` just like Telegram outbound. + +3. Plain lead reply correlation - 🎯 6 πŸ›‘οΈ 8 🧠 7. + - Existing app supports visible lead text, but provider correlation needs single-turn collector. + +4. Projection correctness for teammate-to-user messages - 🎯 7 πŸ›‘οΈ 8 🧠 7. + - Feasible. + - Needs durable source reader and attribution policy. + +5. Stable team/member identity - 🎯 6 πŸ›‘οΈ 8 🧠 6. + - Pass 27 recommendation still stands. + +6. Outbound Telegram `acceptance_unknown` UX - 🎯 7 πŸ›‘οΈ 9 🧠 6. + - Technical state is clear. + - UI wording still needs product work. + +## 12. Revised Next Slice + +Before UI, implement/test in this order: + +1. Internal turn ledger and per-route queue - 🎯 9 πŸ›‘οΈ 9 🧠 7, approx 1800-3600 LOC. +2. Reply collector with explicit `relayOfMessageId` and plain-text fallback - 🎯 8 πŸ›‘οΈ 8 🧠 7, approx 1500-3200 LOC. +3. Projection source reader and projection ledger - 🎯 9 πŸ›‘οΈ 9 🧠 6, approx 1500-3000 LOC. +4. Team/member identity registry from pass 27 - 🎯 9 πŸ›‘οΈ 9 🧠 6, approx 1000-2200 LOC. +5. Telegram topic live probe - 🎯 9 πŸ›‘οΈ 9 🧠 5, approx 700-1500 LOC. + +This gives us a feature that can survive crashes and delayed replies before any polished setup wizard exists. diff --git a/eslint.config.js b/eslint.config.js index e207d1da..1fe2a49c 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -459,6 +459,35 @@ export default defineConfig([ }, }, + { + name: 'electron-main-safe-renderer-send-guard', + files: ['src/main/**/*.ts'], + ignores: ['src/main/utils/safeWebContentsSend.ts'], + rules: { + 'no-restricted-syntax': [ + 'error', + { + selector: + "CallExpression[callee.type='MemberExpression'][callee.property.name='send'][callee.object.type='MemberExpression'][callee.object.property.name='webContents']", + message: + 'Use safeSendToRenderer(...) instead of direct webContents.send(...) in the main process.', + }, + { + selector: + "CallExpression[callee.type='MemberExpression'][callee.property.name='send'][callee.object.type='MemberExpression'][callee.object.property.name='sender']", + message: + 'Use safeSendToRenderer(BrowserWindow.fromWebContents(event.sender), ...) instead of direct event.sender.send(...) in the main process.', + }, + { + selector: + "CallExpression[callee.type='MemberExpression'][callee.property.name='send'][callee.object.name='contents']", + message: + 'Use safeSendToRenderer(...) instead of aliasing webContents and calling contents.send(...) in the main process.', + }, + ], + }, + }, + { name: 'team-transcript-project-resolver-sonar-override', files: ['src/main/services/team/TeamTranscriptProjectResolver.ts'], diff --git a/mcp-server/src/agent-teams-controller.d.ts b/mcp-server/src/agent-teams-controller.d.ts index 9382d425..011c692f 100644 --- a/mcp-server/src/agent-teams-controller.d.ts +++ b/mcp-server/src/agent-teams-controller.d.ts @@ -2,6 +2,7 @@ declare module 'agent-teams-controller' { export interface ControllerContextOptions { teamName: string; claudeDir?: string; + allowUserMessageSender?: boolean; } export interface ControllerTaskApi { diff --git a/mcp-server/src/controller.ts b/mcp-server/src/controller.ts index cbe3e96c..72c8d6b4 100644 --- a/mcp-server/src/controller.ts +++ b/mcp-server/src/controller.ts @@ -23,5 +23,6 @@ export function getController(teamName: string, claudeDir?: string) { return createController({ teamName, ...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}), + allowUserMessageSender: false, }); } diff --git a/mcp-server/src/tools/messageTools.ts b/mcp-server/src/tools/messageTools.ts index 3982bb6f..2c63b023 100644 --- a/mcp-server/src/tools/messageTools.ts +++ b/mcp-server/src/tools/messageTools.ts @@ -14,12 +14,12 @@ export function registerMessageTools(server: Pick) { server.addTool({ name: 'message_send', description: - 'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When replying to an app-delivered OpenCode runtime message, include source="runtime_delivery" and relayOfMessageId with the inbound app messageId. When to is "user", from is required and must be your configured teammate name. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.', + 'Send a visible team/user message into team inbox. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. from is required and must be your configured teammate name; user is reserved for app-owned writes. When replying to an app-delivered OpenCode runtime message, include source="runtime_delivery" and relayOfMessageId with the inbound app messageId. Do not invent placeholder task refs. If the message is not about a real board task, omit # task labels; never use #00000000.', parameters: z.object({ ...toolContextSchema, to: z.string().min(1), text: z.string().min(1), - from: z.string().optional(), + from: z.string().min(1), summary: z.string().optional(), source: z.string().optional(), relayOfMessageId: z.string().optional(), diff --git a/mcp-server/src/tools/taskTools.ts b/mcp-server/src/tools/taskTools.ts index 0785923d..675117f1 100644 --- a/mcp-server/src/tools/taskTools.ts +++ b/mcp-server/src/tools/taskTools.ts @@ -436,12 +436,13 @@ export function registerTaskTools(server: Pick) { server.addTool({ name: 'task_add_comment', - description: 'Add task comment', + description: + 'Add task comment. from is required and must be your configured teammate name; user/system are reserved for app-owned writes.', parameters: z.object({ ...toolContextSchema, taskId: z.string().min(1), text: z.string().min(1), - from: z.string().optional(), + from: z.string().min(1), }), execute: async ({ teamName, claudeDir, taskId, text, from }) => { assertConfiguredTeam(teamName, claudeDir); diff --git a/mcp-server/test/tools.test.ts b/mcp-server/test/tools.test.ts index fdae26fd..26a16407 100644 --- a/mcp-server/test/tools.test.ts +++ b/mcp-server/test/tools.test.ts @@ -1353,6 +1353,7 @@ describe('agent-teams-mcp tools', () => { members: [ { name: 'lead', role: 'team-lead' }, { name: 'alice', role: 'developer' }, + { name: 'bob', role: 'reviewer' }, ], }); diff --git a/package.json b/package.json index c8ec837c..ffb89ac9 100644 --- a/package.json +++ b/package.json @@ -25,7 +25,7 @@ "opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.mjs", "team:prove-launch-matrix": "pnpm exec vitest run --maxWorkers 1 --minWorkers 1 test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts", "prebuild": "tsx scripts/fetch-pricing-data.ts && pnpm --filter agent-teams-controller build && pnpm --filter agent-teams-mcp build", - "build": "electron-vite build", + "build": "node --max-old-space-size=8192 ./node_modules/electron-vite/bin/electron-vite.js build", "dist": "electron-builder --mac --win --linux", "dist:mac": "electron-builder --mac", "dist:mac:arm64": "electron-builder --mac --arm64", diff --git a/packages/agent-graph/src/canvas/draw-agents.ts b/packages/agent-graph/src/canvas/draw-agents.ts index e530b971..a14dbcd2 100644 --- a/packages/agent-graph/src/canvas/draw-agents.ts +++ b/packages/agent-graph/src/canvas/draw-agents.ts @@ -14,7 +14,7 @@ import { MIN_VISIBLE_OPACITY, } from '../constants/canvas-constants'; import { drawHexagon } from './draw-misc'; -import { getAgentGlowSprite, ensureHex, hexWithAlpha } from './render-cache'; +import { getAgentGlowSprite, hexWithAlpha } from './render-cache'; /** * Draw all member/lead nodes on the canvas. @@ -128,7 +128,6 @@ export function drawAgents( y, r, labelText, - color, node.runtimeLabel, node.launchStatusLabel, node.launchVisualState @@ -655,7 +654,7 @@ function drawAvatar( isLead: boolean, avatarUrl?: string ): void { - const avatarR = r * 0.6; + const avatarR = r * AGENT_DRAW.avatarRadiusScale; // Try to draw avatar image if (avatarUrl) { @@ -688,17 +687,15 @@ function drawLabel( y: number, r: number, label: string, - color: string, runtimeLabel?: string, launchStatusLabel?: string, launchVisualState?: GraphNode['launchVisualState'] ): void { const labelY = y + r + AGENT_DRAW.labelYOffset; - ctx.font = '9px monospace'; + ctx.font = 'bold 10px monospace'; ctx.textAlign = 'center'; ctx.textBaseline = 'top'; - ctx.fillStyle = color; - ctx.fillText(label, x, labelY); + drawLabelText(ctx, label, x, labelY, '#e8f8ff', 12); const trimmedRuntimeLabel = runtimeLabel?.trim(); const trimmedLaunchStatusLabel = launchStatusLabel?.trim(); @@ -706,21 +703,68 @@ function drawLabel( return; } - let nextLineY = labelY + 10; + let nextLineY = labelY + 11; if (trimmedRuntimeLabel) { ctx.font = '8px monospace'; - ctx.fillStyle = hexWithAlpha(ensureHex(color), 0.72); - ctx.fillText(truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY); + drawLabelText(ctx, truncateSubLabel(ctx, trimmedRuntimeLabel, r), x, nextLineY, '#b9d7f2', 10); nextLineY += 10; } if (trimmedLaunchStatusLabel) { ctx.font = '7px monospace'; - ctx.fillStyle = getLaunchStatusColor(launchVisualState); - ctx.fillText(truncateSubLabel(ctx, trimmedLaunchStatusLabel, r), x, nextLineY); + drawLabelText( + ctx, + truncateSubLabel(ctx, trimmedLaunchStatusLabel, r), + x, + nextLineY, + getLaunchStatusColor(launchVisualState), + 9 + ); } } +function drawLabelText( + ctx: CanvasRenderingContext2D, + text: string, + x: number, + y: number, + fillStyle: string, + lineHeight: number +): void { + const textWidth = ctx.measureText(text).width; + const paddingX = 5; + const paddingY = 1.5; + + ctx.save(); + ctx.globalAlpha = Math.max(ctx.globalAlpha, 0.88); + ctx.beginPath(); + ctx.roundRect( + x - textWidth / 2 - paddingX, + y - paddingY, + textWidth + paddingX * 2, + lineHeight, + 4 + ); + ctx.fillStyle = 'rgba(2, 6, 23, 0.78)'; + ctx.fill(); + ctx.strokeStyle = 'rgba(148, 213, 255, 0.18)'; + ctx.lineWidth = 1; + ctx.stroke(); + + ctx.fillStyle = fillStyle; + drawTextWithHalo(ctx, text, x, y); + ctx.restore(); +} + +function drawTextWithHalo(ctx: CanvasRenderingContext2D, text: string, x: number, y: number): void { + ctx.save(); + ctx.lineWidth = 4; + ctx.strokeStyle = 'rgba(0, 0, 0, 0.96)'; + ctx.strokeText(text, x, y); + ctx.restore(); + ctx.fillText(text, x, y); +} + function truncateSubLabel(ctx: CanvasRenderingContext2D, label: string, r: number): string { const maxWidth = Math.max(132, r * AGENT_DRAW.labelWidthMultiplier * 2); if (ctx.measureText(label).width <= maxWidth) return label; diff --git a/packages/agent-graph/src/constants/canvas-constants.ts b/packages/agent-graph/src/constants/canvas-constants.ts index 30f6363f..90c576a7 100644 --- a/packages/agent-graph/src/constants/canvas-constants.ts +++ b/packages/agent-graph/src/constants/canvas-constants.ts @@ -58,9 +58,9 @@ export const FORCE = { export const NODE = { /** Lead agent radius */ - radiusLead: 32, + radiusLead: 38, /** Team member radius */ - radiusMember: 24, + radiusMember: 30, /** Process node radius */ radiusProcess: 14, /** Cross-team ghost node radius */ @@ -110,6 +110,7 @@ export const AGENT_DRAW = { sparkScale: 0.45, sparkViewBox: 256, subIconScale: 0.45, + avatarRadiusScale: 0.74, } as const; // ─── Context ring (lead node only) ───────────────────────────────────────── diff --git a/runtime.lock.json b/runtime.lock.json index e1bc58d2..11995845 100644 --- a/runtime.lock.json +++ b/runtime.lock.json @@ -1,27 +1,27 @@ { - "version": "0.0.10", - "sourceRef": "v0.0.10", + "version": "0.0.12", + "sourceRef": "v0.0.12", "sourceRepository": "777genius/agent_teams_orchestrator", "releaseRepository": "777genius/claude_agent_teams_ui", "releaseTag": "v1.2.0", "assets": { "darwin-arm64": { - "file": "agent-teams-runtime-darwin-arm64-v0.0.10.tar.gz", + "file": "agent-teams-runtime-darwin-arm64-v0.0.12.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "darwin-x64": { - "file": "agent-teams-runtime-darwin-x64-v0.0.10.tar.gz", + "file": "agent-teams-runtime-darwin-x64-v0.0.12.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "linux-x64": { - "file": "agent-teams-runtime-linux-x64-v0.0.10.tar.gz", + "file": "agent-teams-runtime-linux-x64-v0.0.12.tar.gz", "archiveKind": "tar.gz", "binaryName": "claude-multimodel" }, "win32-x64": { - "file": "agent-teams-runtime-win32-x64-v0.0.10.zip", + "file": "agent-teams-runtime-win32-x64-v0.0.12.zip", "archiveKind": "zip", "binaryName": "claude-multimodel.exe" } diff --git a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts index 8c19acca..eefda424 100644 --- a/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts +++ b/src/features/agent-graph/renderer/adapters/TeamGraphAdapter.ts @@ -415,6 +415,7 @@ export class TeamGraphAdapter { ): void { const percent = leadContext?.contextUsedPercent; const leadMember = data.members.find((member) => member.name === leadName); + const isTeamVisualOnline = data.isAlive || isTeamProvisioning; const activeTool = TeamGraphAdapter.#selectVisibleTool( activeTools?.[leadName], finishedVisible?.[leadName] @@ -437,7 +438,7 @@ export class TeamGraphAdapter { }) : null; const leadState = - leadActivity === 'offline' + !isTeamVisualOnline || leadActivity === 'offline' ? 'terminated' : leadActivity === 'idle' ? 'idle' @@ -445,7 +446,7 @@ export class TeamGraphAdapter { ? 'tool_calling' : 'active'; const leadException = - leadActivity === 'offline' + !isTeamVisualOnline || leadActivity === 'offline' ? { exceptionTone: 'error' as const, exceptionLabel: 'offline' } : pendingApproval ? { exceptionTone: 'warning' as const, exceptionLabel: 'awaiting approval' } @@ -455,7 +456,7 @@ export class TeamGraphAdapter { kind: 'lead', label: data.config.name || teamName, state: leadState, - color: data.config.color ?? undefined, + color: isTeamVisualOnline ? (data.config.color ?? undefined) : undefined, runtimeLabel: TeamGraphAdapter.#getRuntimeLabel( leadMember?.providerId, leadMember?.model, @@ -465,8 +466,8 @@ export class TeamGraphAdapter { launchStatusLabel: leadLaunchPresentation?.launchStatusLabel ?? undefined, contextUsage: percent != null ? Math.max(0, Math.min(1, percent / 100)) : undefined, avatarUrl: leadMember - ? resolveMemberAvatarUrl(leadMember, avatarMap, 64) - : agentAvatarUrl(leadName, 64), + ? resolveMemberAvatarUrl(leadMember, avatarMap, 96) + : agentAvatarUrl(leadName, 96), pendingApproval, activeTool: activeTool ? { @@ -516,6 +517,7 @@ export class TeamGraphAdapter { if (member.removedAt) continue; if (isLeadMember(member)) continue; + const isTeamVisualOnline = data.isAlive || isTeamProvisioning; const memberId = memberNodeIdByAlias.get(member.name) ?? buildGraphMemberNodeIdForMember(teamName, member); const spawn = spawnStatuses?.[member.name]; @@ -546,20 +548,26 @@ export class TeamGraphAdapter { id: memberId, kind: 'member', label: member.name, - state: hasRunningTool - ? 'tool_calling' - : TeamGraphAdapter.#mapMemberStatus(member.status, spawn), - color: member.color ?? undefined, + state: !isTeamVisualOnline + ? 'terminated' + : hasRunningTool + ? 'tool_calling' + : TeamGraphAdapter.#mapMemberStatus(member.status, spawn), + color: isTeamVisualOnline ? (member.color ?? undefined) : undefined, role: member.role ?? undefined, runtimeLabel: TeamGraphAdapter.#getRuntimeLabel( member.providerId, member.model, member.effort ), - spawnStatus: spawn?.status, - launchVisualState: launchPresentation.launchVisualState ?? undefined, - launchStatusLabel: launchPresentation.launchStatusLabel ?? undefined, - avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 64), + spawnStatus: isTeamVisualOnline ? spawn?.status : undefined, + launchVisualState: isTeamVisualOnline + ? (launchPresentation.launchVisualState ?? undefined) + : undefined, + launchStatusLabel: isTeamVisualOnline + ? (launchPresentation.launchStatusLabel ?? undefined) + : undefined, + avatarUrl: resolveMemberAvatarUrl(member, avatarMap, 96), currentTaskId: member.currentTaskId ?? undefined, currentTaskSubject: member.currentTaskId ? data.tasks.find((t) => t.id === member.currentTaskId)?.subject diff --git a/src/features/codex-account/contracts/dto.ts b/src/features/codex-account/contracts/dto.ts index 27e45e94..341ab757 100644 --- a/src/features/codex-account/contracts/dto.ts +++ b/src/features/codex-account/contracts/dto.ts @@ -64,6 +64,11 @@ export interface CodexLoginStateDto { startedAt: string | null; } +export interface CodexRuntimeContextDto { + binaryPath: string | null; + codexHome: string | null; +} + export interface CodexAccountSnapshotDto { preferredAuthMode: CodexAccountAuthMode; effectiveAuthMode: CodexAccountEffectiveAuthMode; @@ -77,6 +82,7 @@ export interface CodexAccountSnapshotDto { requiresOpenaiAuth: boolean | null; localAccountArtifactsPresent?: boolean; localActiveChatgptAccountPresent?: boolean; + runtimeContext?: CodexRuntimeContextDto; login: CodexLoginStateDto; rateLimits: CodexRateLimitSnapshotDto | null; updatedAt: string; diff --git a/src/features/codex-account/main/composition/createCodexAccountFeature.ts b/src/features/codex-account/main/composition/createCodexAccountFeature.ts index 8e95422a..9e5a54b6 100644 --- a/src/features/codex-account/main/composition/createCodexAccountFeature.ts +++ b/src/features/codex-account/main/composition/createCodexAccountFeature.ts @@ -48,6 +48,16 @@ interface CodexLastKnownRateLimits { observedAt: number; } +interface CodexRuntimeContext { + binaryPath: string | null; + codexHome: string | null; +} + +interface CodexLastKnownRuntimeContext { + payload: CodexRuntimeContext; + observedAt: number; +} + interface CodexSnapshotRefreshOptions { includeRateLimits: boolean; forceRefreshToken: boolean; @@ -130,6 +140,16 @@ function asRateLimits( }; } +function createRuntimeContext( + binaryPath: string | null | undefined, + codexHome: string | null | undefined +): CodexRuntimeContext { + return { + binaryPath: binaryPath?.trim() || null, + codexHome: codexHome?.trim() || null, + }; +} + function getPreferredAuthMode(configManager: { getConfig: () => { providerConnections: { @@ -236,6 +256,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { private pendingRefreshOptions: CodexSnapshotRefreshOptions | null = null; private lastKnownAccount: CodexLastKnownAccount | null = null; private lastKnownRateLimits: CodexLastKnownRateLimits | null = null; + private lastKnownRuntimeContext: CodexLastKnownRuntimeContext | null = null; private mutationQueue: Promise = Promise.resolve(); private mutationQueueRelease: (() => void) | null = null; private activeMutationCount = 0; @@ -372,6 +393,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { this.pendingRefreshOptions = null; this.lastKnownAccount = null; this.lastKnownRateLimits = null; + this.lastKnownRuntimeContext = null; this.activeMutationCount = 0; if (this.mutationQueueRelease) { this.mutationQueueRelease(); @@ -441,6 +463,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { let appServerStatusMessage: string | null = null; let accountPayload = this.lastKnownAccount?.payload ?? null; let requiresOpenaiAuth: boolean | null = accountPayload?.requiresOpenaiAuth ?? null; + let runtimeContext = createRuntimeContext(binaryPath, null); try { const accountResult = await this.appServerClient.readAccount({ @@ -448,6 +471,13 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { env, refreshToken: options?.forceRefreshToken ?? false, }); + runtimeContext = createRuntimeContext(binaryPath, accountResult.initialize.codexHome); + if (runtimeContext.codexHome) { + this.lastKnownRuntimeContext = { + payload: runtimeContext, + observedAt: now, + }; + } const canReuseLastKnownManagedAccount = options?.forceRefreshToken !== true && localActiveChatgptAccountPresent && @@ -483,6 +513,14 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { accountPayload = this.lastKnownAccount.payload; requiresOpenaiAuth = this.lastKnownAccount.payload.requiresOpenaiAuth; } + + if ( + this.lastKnownRuntimeContext && + now - this.lastKnownRuntimeContext.observedAt <= LAST_KNOWN_GOOD_MANAGED_ACCOUNT_TTL_MS && + this.lastKnownRuntimeContext.payload.binaryPath === binaryPath + ) { + runtimeContext = this.lastKnownRuntimeContext.payload; + } } let rateLimits: CodexRateLimitSnapshotDto | null = null; @@ -542,6 +580,7 @@ class CodexAccountFeatureFacadeImpl implements CodexAccountFeatureFacade { requiresOpenaiAuth, localAccountArtifactsPresent, localActiveChatgptAccountPresent, + runtimeContext, login, rateLimits, updatedAt: new Date(now).toISOString(), diff --git a/src/features/codex-model-catalog/core/domain/__tests__/codexModelCatalogFallback.test.ts b/src/features/codex-model-catalog/core/domain/__tests__/codexModelCatalogFallback.test.ts new file mode 100644 index 00000000..7867e98d --- /dev/null +++ b/src/features/codex-model-catalog/core/domain/__tests__/codexModelCatalogFallback.test.ts @@ -0,0 +1,12 @@ +import { describe, expect, it } from 'vitest'; + +import { createStaticCodexModelCatalogModels } from '../codexModelCatalogFallback'; + +describe('createStaticCodexModelCatalogModels', () => { + it('includes GPT-5.5 without changing the default from GPT-5.4', () => { + const models = createStaticCodexModelCatalogModels(); + + expect(models.map((model) => model.launchModel)).toContain('gpt-5.5'); + expect(models.find((model) => model.isDefault)?.launchModel).toBe('gpt-5.4'); + }); +}); diff --git a/src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts b/src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts index 5f9c724c..aac4d20c 100644 --- a/src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts +++ b/src/features/codex-model-catalog/core/domain/codexModelCatalogFallback.ts @@ -36,6 +36,11 @@ export function createStaticCodexModelCatalogModels(): CliProviderModelCatalogIt badgeLabel: '5.4', isDefault: true, }), + createFallbackModel({ + id: 'gpt-5.5', + displayName: 'GPT-5.5', + badgeLabel: '5.5', + }), createFallbackModel({ id: 'gpt-5.4-mini', displayName: 'GPT-5.4 Mini', diff --git a/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts new file mode 100644 index 00000000..7f1b517e --- /dev/null +++ b/src/features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter.ts @@ -0,0 +1,287 @@ +import { createReadStream } from 'node:fs'; +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; +import readline from 'node:readline'; + +import { normalizeIdentityPath } from '@features/recent-projects/main/infrastructure/identity/normalizeIdentityPath'; +import { isEphemeralProjectPath } from '@shared/utils/ephemeralProjectPath'; + +import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; +import type { + RecentProjectsSourcePort, + RecentProjectsSourceResult, +} from '@features/recent-projects/core/application/ports/RecentProjectsSourcePort'; +import type { RecentProjectCandidate } from '@features/recent-projects/core/domain/models/RecentProjectCandidate'; +import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver'; +import type { ServiceContext } from '@main/services'; + +const CODEX_SESSION_FILE_PARSE_LIMIT = 500; +const CODEX_PROJECT_CANDIDATE_LIMIT = 40; +const CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS = 3_500; +const CODEX_SESSION_FILE_READ_BATCH_SIZE = 24; + +interface CodexSessionFileEntry { + filePath: string; + mtimeMs: number; +} + +interface CodexSessionEvent { + timestamp?: unknown; + payload?: { + cwd?: unknown; + source?: unknown; + timestamp?: unknown; + git?: { + branch?: unknown; + } | null; + }; +} + +interface CodexSessionProjectSnapshot { + cwd: string; + source: unknown; + lastActivityAt: number; + branchName?: string; +} + +function isInteractiveSource(source: unknown): boolean { + return source === 'vscode' || source === 'cli'; +} + +function normalizeTimestamp(value: unknown): number { + if (typeof value === 'number' && Number.isFinite(value)) { + return value < 1_000_000_000_000 ? value * 1000 : value; + } + + if (typeof value === 'string' && value.trim()) { + const parsed = Date.parse(value); + return Number.isNaN(parsed) ? 0 : parsed; + } + + return 0; +} + +function getCodexHome(codexHome?: string): string { + return codexHome?.trim() || process.env.CODEX_HOME?.trim() || path.join(os.homedir(), '.codex'); +} + +async function readFirstLine(filePath: string): Promise { + const stream = createReadStream(filePath, { encoding: 'utf8' }); + const lines = readline.createInterface({ + input: stream, + crlfDelay: Infinity, + }); + + try { + for await (const line of lines) { + return line; + } + return null; + } catch { + return null; + } finally { + lines.close(); + stream.destroy(); + } +} + +async function listJsonlFiles(root: string, maxDepth: number): Promise { + async function walk(directory: string, depth: number): Promise { + let entries; + try { + entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' }); + } catch { + return []; + } + + const files = await Promise.all( + entries.map(async (entry): Promise => { + const entryPath = path.join(directory, entry.name); + if (entry.isDirectory()) { + return depth < maxDepth ? walk(entryPath, depth + 1) : []; + } + + if (!entry.isFile() || !entry.name.endsWith('.jsonl')) { + return []; + } + + try { + const stats = await fs.stat(entryPath); + return [ + { + filePath: entryPath, + mtimeMs: stats.mtimeMs, + }, + ]; + } catch { + return []; + } + }) + ); + + return files.flat(); + } + + return walk(root, 0); +} + +function parseSessionSnapshot( + firstLine: string, + mtimeMs: number +): CodexSessionProjectSnapshot | null { + let event: CodexSessionEvent; + try { + event = JSON.parse(firstLine) as CodexSessionEvent; + } catch { + return null; + } + + const cwd = typeof event.payload?.cwd === 'string' ? event.payload.cwd.trim() : ''; + if (!cwd || !isInteractiveSource(event.payload?.source) || isEphemeralProjectPath(cwd)) { + return null; + } + + const timestamp = + mtimeMs || normalizeTimestamp(event.payload?.timestamp) || normalizeTimestamp(event.timestamp); + const branchName = + typeof event.payload?.git?.branch === 'string' ? event.payload.git.branch.trim() : ''; + + return { + cwd, + source: event.payload?.source, + lastActivityAt: timestamp, + branchName: branchName || undefined, + }; +} + +export class CodexSessionFileRecentProjectsSourceAdapter implements RecentProjectsSourcePort { + readonly sourceId = 'codex-session-files'; + readonly timeoutMs = CODEX_SESSION_FILE_SOURCE_TIMEOUT_MS; + readonly #codexHome: string; + + constructor( + private readonly deps: { + getActiveContext: () => ServiceContext; + getLocalContext: () => ServiceContext | undefined; + identityResolver: RecentProjectIdentityResolver; + logger: LoggerPort; + codexHome?: string; + } + ) { + this.#codexHome = getCodexHome(deps.codexHome); + } + + async list(): Promise { + const activeContext = this.deps.getActiveContext(); + const localContext = this.deps.getLocalContext(); + + if (activeContext.type !== 'local' || activeContext.id !== localContext?.id) { + return { + candidates: [], + degraded: false, + }; + } + + try { + const snapshots = await this.#listRecentSessionSnapshots(); + const candidates = await Promise.all( + snapshots.map((snapshot) => this.#toCandidate(snapshot)) + ); + + const validCandidates = candidates.filter( + (candidate): candidate is RecentProjectCandidate => candidate !== null + ); + + this.deps.logger.info('codex session-file recent-projects source loaded', { + count: validCandidates.length, + codexHome: this.#codexHome, + }); + + return { + candidates: validCandidates, + degraded: false, + }; + } catch (error) { + this.deps.logger.warn('codex session-file recent-projects source failed', { + error: error instanceof Error ? error.message : String(error), + }); + + return { + candidates: [], + degraded: true, + }; + } + } + + async #listRecentSessionSnapshots(): Promise { + const files = [ + ...(await listJsonlFiles(path.join(this.#codexHome, 'sessions'), 4)), + ...(await listJsonlFiles(path.join(this.#codexHome, 'archived_sessions'), 1)), + ].sort((left, right) => right.mtimeMs - left.mtimeMs); + + const snapshotsByCwd = new Map(); + + const candidateFiles = files.slice(0, CODEX_SESSION_FILE_PARSE_LIMIT); + + for ( + let offset = 0; + offset < candidateFiles.length && snapshotsByCwd.size < CODEX_PROJECT_CANDIDATE_LIMIT; + offset += CODEX_SESSION_FILE_READ_BATCH_SIZE + ) { + const batch = candidateFiles.slice(offset, offset + CODEX_SESSION_FILE_READ_BATCH_SIZE); + const firstLines = await Promise.all( + batch.map(async (file) => ({ + file, + firstLine: await readFirstLine(file.filePath), + })) + ); + + for (const { file, firstLine } of firstLines) { + if (!firstLine) { + continue; + } + + const snapshot = parseSessionSnapshot(firstLine, file.mtimeMs); + if (!snapshot) { + continue; + } + + const previous = snapshotsByCwd.get(snapshot.cwd); + if (!previous || snapshot.lastActivityAt > previous.lastActivityAt) { + snapshotsByCwd.set(snapshot.cwd, snapshot); + } + + if (snapshotsByCwd.size >= CODEX_PROJECT_CANDIDATE_LIMIT) { + break; + } + } + } + + return Array.from(snapshotsByCwd.values()) + .sort((left, right) => right.lastActivityAt - left.lastActivityAt) + .slice(0, CODEX_PROJECT_CANDIDATE_LIMIT); + } + + async #toCandidate( + snapshot: CodexSessionProjectSnapshot + ): Promise { + const identity = await this.deps.identityResolver.resolve(snapshot.cwd); + const displayName = identity?.name ?? path.basename(snapshot.cwd) ?? snapshot.cwd; + + return { + identity: identity?.id ?? `path:${normalizeIdentityPath(snapshot.cwd)}`, + displayName, + primaryPath: snapshot.cwd, + associatedPaths: [snapshot.cwd], + lastActivityAt: snapshot.lastActivityAt, + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: snapshot.cwd, + }, + branchName: snapshot.branchName, + }; + } +} diff --git a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts index b0f36022..c85173d0 100644 --- a/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts +++ b/src/features/recent-projects/main/composition/createRecentProjectsFeature.ts @@ -2,17 +2,12 @@ import { type DashboardRecentProjectsPayload, normalizeDashboardRecentProjectsPayload, } from '@features/recent-projects/contracts'; -import { - CodexBinaryResolver, - JsonRpcStdioClient, -} from '@main/services/infrastructure/codexAppServer'; import { ListDashboardRecentProjectsUseCase } from '../../core/application/use-cases/ListDashboardRecentProjectsUseCase'; import { DashboardRecentProjectsPresenter } from '../adapters/output/presenters/DashboardRecentProjectsPresenter'; import { ClaudeRecentProjectsSourceAdapter } from '../adapters/output/sources/ClaudeRecentProjectsSourceAdapter'; -import { CodexRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexRecentProjectsSourceAdapter'; +import { CodexSessionFileRecentProjectsSourceAdapter } from '../adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter'; import { InMemoryRecentProjectsCache } from '../infrastructure/cache/InMemoryRecentProjectsCache'; -import { CodexAppServerClient } from '../infrastructure/codex/CodexAppServerClient'; import { RecentProjectIdentityResolver } from '../infrastructure/identity/RecentProjectIdentityResolver'; import type { ClockPort } from '../../core/application/ports/ClockPort'; @@ -31,16 +26,12 @@ export function createRecentProjectsFeature(deps: { const cache = new InMemoryRecentProjectsCache(); const presenter = new DashboardRecentProjectsPresenter(); const clock: ClockPort = { now: () => Date.now() }; - const jsonRpcStdioClient = new JsonRpcStdioClient(deps.logger); - const codexAppServerClient = new CodexAppServerClient(jsonRpcStdioClient); const identityResolver = new RecentProjectIdentityResolver(); const sources = [ new ClaudeRecentProjectsSourceAdapter(deps.getActiveContext, deps.logger), - new CodexRecentProjectsSourceAdapter({ + new CodexSessionFileRecentProjectsSourceAdapter({ getActiveContext: deps.getActiveContext, getLocalContext: deps.getLocalContext, - resolveBinary: () => CodexBinaryResolver.resolve(), - appServerClient: codexAppServerClient, identityResolver, logger: deps.logger, }), diff --git a/src/features/runtime-provider-management/contracts/api.ts b/src/features/runtime-provider-management/contracts/api.ts index e25f8917..eea72627 100644 --- a/src/features/runtime-provider-management/contracts/api.ts +++ b/src/features/runtime-provider-management/contracts/api.ts @@ -4,11 +4,11 @@ import type { RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, + RuntimeProviderManagementLoadModelsInput, RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadViewInput, - RuntimeProviderManagementLoadModelsInput, - RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelsResponse, + RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetDefaultModelInput, RuntimeProviderManagementSetupFormResponse, diff --git a/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts b/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts index bad1c703..6eddf5ec 100644 --- a/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts +++ b/src/features/runtime-provider-management/core/application/runtimeProviderManagementUseCases.ts @@ -5,11 +5,11 @@ import type { RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, - RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadModelsInput, + RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadViewInput, - RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelsResponse, + RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetDefaultModelInput, RuntimeProviderManagementSetupFormResponse, diff --git a/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts b/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts index bc52cc00..0dbd5713 100644 --- a/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts +++ b/src/features/runtime-provider-management/main/adapters/input/registerRuntimeProviderManagementIpc.ts @@ -18,11 +18,11 @@ import type { RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, - RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadModelsInput, + RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadViewInput, - RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelsResponse, + RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetDefaultModelInput, RuntimeProviderManagementSetupFormResponse, diff --git a/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts b/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts index 02eda5a0..050ef2b3 100644 --- a/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts +++ b/src/features/runtime-provider-management/main/composition/createRuntimeProviderManagementFeature.ts @@ -8,11 +8,11 @@ import type { RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, - RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadModelsInput, + RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadViewInput, - RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelsResponse, + RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetDefaultModelInput, RuntimeProviderManagementSetupFormResponse, diff --git a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts index 175f5989..85f0a39d 100644 --- a/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts +++ b/src/features/runtime-provider-management/main/infrastructure/AgentTeamsRuntimeProviderManagementCliClient.ts @@ -11,8 +11,8 @@ import type { RuntimeProviderManagementErrorDto, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, - RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadModelsInput, + RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadViewInput, RuntimeProviderManagementModelsResponse, RuntimeProviderManagementModelTestResponse, diff --git a/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts b/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts index 9bc0c4ac..a8a6ec9b 100644 --- a/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts +++ b/src/features/runtime-provider-management/preload/createRuntimeProviderManagementBridge.ts @@ -17,11 +17,11 @@ import type { RuntimeProviderManagementDirectoryResponse, RuntimeProviderManagementForgetInput, RuntimeProviderManagementLoadDirectoryInput, - RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadModelsInput, + RuntimeProviderManagementLoadSetupFormInput, RuntimeProviderManagementLoadViewInput, - RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementModelsResponse, + RuntimeProviderManagementModelTestResponse, RuntimeProviderManagementProviderResponse, RuntimeProviderManagementSetDefaultModelInput, RuntimeProviderManagementSetupFormResponse, diff --git a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx index 7f1bf6a6..2f094e24 100644 --- a/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx +++ b/src/features/runtime-provider-management/renderer/ui/RuntimeProviderManagementPanelView.tsx @@ -77,7 +77,7 @@ interface ProviderRowProps { readonly actions: RuntimeProviderManagementActions; } -const DIRECTORY_FILTERS: Array<{ id: RuntimeProviderDirectoryFilterDto; label: string }> = [ +const DIRECTORY_FILTERS: { id: RuntimeProviderDirectoryFilterDto; label: string }[] = [ { id: 'all', label: 'All' }, { id: 'connectable', label: 'Connectable' }, { id: 'connected', label: 'Connected' }, diff --git a/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx b/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx index 25f6678e..3bbda2e2 100644 --- a/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx +++ b/src/features/runtime-provider-management/renderer/ui/providerBrandIcons.tsx @@ -2,10 +2,10 @@ import { useEffect, useState } from 'react'; import type { CSSProperties, JSX } from 'react'; -type ProviderBrand = { +interface ProviderBrand { providerId: string; displayName: string; -}; +} interface SvgPath { d: string; diff --git a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts index bb429c5d..ffbe971d 100644 --- a/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts +++ b/src/features/team-runtime-lanes/core/domain/buildMixedPersistedLaunchSnapshot.ts @@ -146,6 +146,16 @@ function createPrimaryLaneMemberState(params: { const runtime = params.status; const strongRuntimeAlive = preservesStrongRuntimeAlive(runtime ?? {}); const sources = runtime ? createSourcesFromStatus(runtime) : undefined; + const launchState = + runtime?.launchState ?? + deriveMemberLaunchState({ + hardFailure: runtime?.hardFailure, + bootstrapConfirmed: runtime?.bootstrapConfirmed, + runtimeAlive: strongRuntimeAlive, + agentToolAccepted: runtime?.agentToolAccepted, + pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds, + }); + const hardFailure = runtime?.hardFailure === true || launchState === 'failed_to_start'; const base: PersistedTeamLaunchMemberState = { name: params.member.name.trim(), providerId, @@ -173,20 +183,12 @@ function createPrimaryLaneMemberState(params: { providerId === params.leadDefaults.providerId ? (params.leadDefaults.launchIdentity ?? undefined) : undefined, - launchState: - runtime?.launchState ?? - deriveMemberLaunchState({ - hardFailure: runtime?.hardFailure, - bootstrapConfirmed: runtime?.bootstrapConfirmed, - runtimeAlive: strongRuntimeAlive, - agentToolAccepted: runtime?.agentToolAccepted, - pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds, - }), + launchState, agentToolAccepted: runtime?.agentToolAccepted === true, runtimeAlive: strongRuntimeAlive, bootstrapConfirmed: runtime?.bootstrapConfirmed === true, - hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', - hardFailureReason: runtime?.hardFailureReason ?? runtime?.error, + hardFailure, + hardFailureReason: hardFailure ? (runtime?.hardFailureReason ?? runtime?.error) : undefined, pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length ? [...new Set(runtime.pendingPermissionRequestIds)] : undefined, @@ -212,7 +214,6 @@ function createSecondaryLaneMemberState( normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId; const evidence = params.evidence; const strongRuntimeAlive = preservesStrongRuntimeAlive(evidence ?? {}); - const hardFailureReason = evidence?.hardFailureReason; const launchState = evidence?.launchState ?? deriveMemberLaunchState({ @@ -222,6 +223,8 @@ function createSecondaryLaneMemberState( agentToolAccepted: evidence?.agentToolAccepted, pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds, }); + const hardFailure = evidence?.hardFailure === true || launchState === 'failed_to_start'; + const hardFailureReason = hardFailure ? evidence?.hardFailureReason : undefined; const base: PersistedTeamLaunchMemberState = { name: params.member.name.trim(), providerId, @@ -249,7 +252,7 @@ function createSecondaryLaneMemberState( agentToolAccepted: evidence?.agentToolAccepted === true, runtimeAlive: strongRuntimeAlive, bootstrapConfirmed: evidence?.bootstrapConfirmed === true, - hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start', + hardFailure, hardFailureReason, pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length ? [...new Set(evidence.pendingPermissionRequestIds)] diff --git a/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts b/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts index 7ddbc914..a5875ba9 100644 --- a/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts +++ b/src/features/team-runtime-lanes/main/composition/__tests__/createTeamRuntimeLaneCoordinator.test.ts @@ -47,4 +47,49 @@ describe('createTeamRuntimeLaneCoordinator', () => { }) ).toThrow('Mixed teams with OpenCode side lanes require the OpenCode runtime adapter'); }); + + it('drops stale hard-failure reasons when secondary OpenCode evidence later confirms alive', () => { + const coordinator = createTeamRuntimeLaneCoordinator(); + + const snapshot = coordinator.buildAggregateLaunchSnapshot({ + teamName: 'mixed-team', + launchPhase: 'active', + leadDefaults: { + providerId: 'codex', + }, + primaryMembers: [], + primaryStatuses: {}, + secondaryMembers: [ + { + laneId: 'secondary:opencode:jack', + member: { + name: 'jack', + providerId: 'opencode', + model: 'qwen/qwen3-coder', + }, + leadDefaults: { + providerId: 'codex', + }, + evidence: { + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + hardFailureReason: 'OpenCode bridge reported member launch failure', + diagnostics: ['OpenCode runtime bootstrap check-in accepted'], + }, + }, + ], + }); + + expect(snapshot.members.jack).toMatchObject({ + launchState: 'confirmed_alive', + hardFailure: false, + hardFailureReason: undefined, + }); + expect(snapshot.members.jack.diagnostics).not.toContain( + 'hard failure reason: OpenCode bridge reported member launch failure' + ); + }); }); diff --git a/src/features/tmux-installer/main/composition/runtimeSupport.ts b/src/features/tmux-installer/main/composition/runtimeSupport.ts index 0ae0c94e..08c4c859 100644 --- a/src/features/tmux-installer/main/composition/runtimeSupport.ts +++ b/src/features/tmux-installer/main/composition/runtimeSupport.ts @@ -40,6 +40,13 @@ export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise< return runtimeCommandExecutor.listRuntimeProcesses(); } +export async function sendKeysToTmuxPaneForCurrentPlatform( + paneId: string, + command: string +): Promise { + await runtimeCommandExecutor.sendKeysToPane(paneId, command); +} + export function killTmuxPaneForCurrentPlatformSync(paneId: string): void { runtimeCommandExecutor.killPaneSync(paneId); invalidateTmuxRuntimeStatusCache(); diff --git a/src/features/tmux-installer/main/index.ts b/src/features/tmux-installer/main/index.ts index d18d99ea..9dbef51e 100644 --- a/src/features/tmux-installer/main/index.ts +++ b/src/features/tmux-installer/main/index.ts @@ -12,6 +12,7 @@ export { listRuntimeProcessesForCurrentTmuxPlatform, listTmuxPanePidsForCurrentPlatform, listTmuxPaneRuntimeInfoForCurrentPlatform, + sendKeysToTmuxPaneForCurrentPlatform, } from './composition/runtimeSupport'; export type { RuntimeProcessTableRow, diff --git a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts index 0500d252..d5e434a7 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/TmuxPlatformCommandExecutor.ts @@ -1,4 +1,7 @@ import { execFile, execFileSync } from 'node:child_process'; +import * as fs from 'node:fs'; +import * as os from 'node:os'; +import * as path from 'node:path'; import { buildEnrichedEnv } from '@main/utils/cliEnv'; import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; @@ -19,6 +22,7 @@ export interface TmuxPaneRuntimeInfo { currentPath?: string; sessionName?: string; windowName?: string; + socketName?: string; } export interface RuntimeProcessTableRow { @@ -61,16 +65,17 @@ export class TmuxPlatformCommandExecutor { this.#packageManagerResolver = packageManagerResolver; } - async execTmux(args: string[], timeout = 5_000): Promise { + async execTmux(args: string[], timeout = 5_000, socketName?: string): Promise { + const effectiveArgs = socketName ? ['-L', socketName, ...args] : args; if (process.platform === 'win32') { - return this.#wslService.execTmux(args, null, timeout); + return this.#wslService.execTmux(effectiveArgs, null, timeout); } await resolveInteractiveShellEnv(); const env = buildEnrichedEnv(); const executable = await this.#resolveNativeTmuxExecutable(env); return new Promise((resolve) => { - execFile(executable, args, { env, timeout }, (error, stdout, stderr) => { + execFile(executable, effectiveArgs, { env, timeout }, (error, stdout, stderr) => { const errorCode = typeof error === 'object' && error !== null && 'code' in error ? (error as NodeJS.ErrnoException).code @@ -85,10 +90,16 @@ export class TmuxPlatformCommandExecutor { } async killPane(paneId: string): Promise { - const result = await this.execTmux(['kill-pane', '-t', paneId], 3_000); - if (result.exitCode !== 0) { - throw new Error(result.stderr || `Failed to kill tmux pane ${paneId}`); + const candidates = await this.#getTmuxSocketCandidates(); + let lastError = ''; + for (const socketName of candidates) { + const result = await this.execTmux(['kill-pane', '-t', paneId], 3_000, socketName); + if (result.exitCode === 0) { + return; + } + lastError = result.stderr || `Failed to kill tmux pane ${paneId}`; } + throw new Error(lastError || `Failed to kill tmux pane ${paneId}`); } async listPaneRuntimeInfo(paneIds: readonly string[]): Promise> { @@ -106,37 +117,48 @@ export class TmuxPlatformCommandExecutor { '#{window_name}', ].join('\t'); - const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000); - if (result.exitCode !== 0) { - throw new Error(result.stderr || 'Failed to list tmux panes'); - } - const wanted = new Set(normalizedPaneIds); const paneInfoById = new Map(); - for (const line of result.stdout.split('\n')) { - const trimmed = line.trim(); - if (!trimmed) continue; - const [ - paneId = '', - rawPid = '', - currentCommand = '', - currentPath = '', - sessionName = '', - windowName = '', - ] = trimmed.split('\t'); - const normalizedPaneId = paneId.trim(); - if (!wanted.has(normalizedPaneId)) continue; - const pid = Number.parseInt(rawPid.trim(), 10); - if (Number.isFinite(pid) && pid > 0) { - paneInfoById.set(normalizedPaneId, { - paneId: normalizedPaneId, - panePid: pid, - currentCommand: currentCommand.trim() || undefined, - currentPath: currentPath.trim() || undefined, - sessionName: sessionName.trim() || undefined, - windowName: windowName.trim() || undefined, - }); + const candidates = await this.#getTmuxSocketCandidates(); + let sawSuccessfulList = false; + let lastError = ''; + + for (const socketName of candidates) { + const result = await this.execTmux(['list-panes', '-a', '-F', format], 3_000, socketName); + if (result.exitCode !== 0) { + lastError = result.stderr || 'Failed to list tmux panes'; + continue; } + sawSuccessfulList = true; + for (const line of result.stdout.split('\n')) { + const trimmed = line.trim(); + if (!trimmed) continue; + const [ + paneId = '', + rawPid = '', + currentCommand = '', + currentPath = '', + sessionName = '', + windowName = '', + ] = trimmed.split('\t'); + const normalizedPaneId = paneId.trim(); + if (!wanted.has(normalizedPaneId) || paneInfoById.has(normalizedPaneId)) continue; + const pid = Number.parseInt(rawPid.trim(), 10); + if (Number.isFinite(pid) && pid > 0) { + paneInfoById.set(normalizedPaneId, { + paneId: normalizedPaneId, + panePid: pid, + currentCommand: currentCommand.trim() || undefined, + currentPath: currentPath.trim() || undefined, + sessionName: sessionName.trim() || undefined, + windowName: windowName.trim() || undefined, + ...(socketName ? { socketName } : {}), + }); + } + } + } + if (!sawSuccessfulList) { + throw new Error(lastError || 'Failed to list tmux panes'); } return paneInfoById; } @@ -157,6 +179,19 @@ export class TmuxPlatformCommandExecutor { return parseRuntimeProcessTable(result.stdout); } + async sendKeysToPane(paneId: string, command: string): Promise { + const paneInfo = await this.listPaneRuntimeInfo([paneId]); + const socketName = paneInfo.get(paneId)?.socketName; + const result = await this.execTmux( + ['send-keys', '-t', paneId, command, 'Enter'], + 3_000, + socketName + ); + if (result.exitCode !== 0) { + throw new Error(result.stderr || `Failed to send command to tmux pane ${paneId}`); + } + } + killPaneSync(paneId: string): void { if (process.platform === 'win32') { const preferredDistro = this.#wslService.getPersistedPreferredDistroSync(); @@ -183,8 +218,24 @@ export class TmuxPlatformCommandExecutor { throw lastError ?? new Error(`Failed to kill tmux pane ${paneId}`); } - // eslint-disable-next-line sonarjs/no-os-command-from-path -- tmux is resolved during runtime readiness checks before this sync cleanup path is used - execFileSync('tmux', ['kill-pane', '-t', paneId], { stdio: 'ignore' }); + const candidates = this.#getTmuxSocketCandidatesSync(); + let lastError: Error | null = null; + for (const socketName of candidates) { + try { + execFileSync( + // eslint-disable-next-line sonarjs/no-os-command-from-path -- tmux is resolved during runtime readiness checks before this sync cleanup path is used + 'tmux', + [...(socketName ? ['-L', socketName] : []), 'kill-pane', '-t', paneId], + { + stdio: 'ignore', + } + ); + return; + } catch (error) { + lastError = error instanceof Error ? error : new Error(String(error)); + } + } + throw lastError ?? new Error(`Failed to kill tmux pane ${paneId}`); } #getWslExecutableCandidates(): string[] { @@ -221,6 +272,69 @@ export class TmuxPlatformCommandExecutor { }); } + async #getTmuxSocketCandidates(): Promise<(string | undefined)[]> { + if (process.platform === 'win32') { + return [undefined]; + } + return [...(await this.#listNativeSwarmSocketNames()), undefined]; + } + + #getTmuxSocketCandidatesSync(): (string | undefined)[] { + if (process.platform === 'win32') { + return [undefined]; + } + return [...this.#listNativeSwarmSocketNamesSync(), undefined]; + } + + async #listNativeSwarmSocketNames(): Promise { + const dirs = this.#getNativeTmuxSocketDirs(); + const names = new Set(); + await Promise.all( + dirs.map(async (dir) => { + let entries: string[]; + try { + entries = await fs.promises.readdir(dir); + } catch { + return; + } + for (const entry of entries) { + if (entry.startsWith('claude-swarm-')) { + names.add(entry); + } + } + }) + ); + return [...names].sort((left, right) => left.localeCompare(right)); + } + + #listNativeSwarmSocketNamesSync(): string[] { + const names = new Set(); + for (const dir of this.#getNativeTmuxSocketDirs()) { + let entries: string[]; + try { + entries = fs.readdirSync(dir); + } catch { + continue; + } + for (const entry of entries) { + if (entry.startsWith('claude-swarm-')) { + names.add(entry); + } + } + } + return [...names].sort((left, right) => left.localeCompare(right)); + } + + #getNativeTmuxSocketDirs(): string[] { + const uid = typeof process.getuid === 'function' ? process.getuid() : os.userInfo().uid; + const candidates = [ + path.join('/tmp', `tmux-${uid}`), + path.join('/private/tmp', `tmux-${uid}`), + path.join(os.tmpdir(), `tmux-${uid}`), + ]; + return [...new Set(candidates)]; + } + async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise { const platform = process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32' diff --git a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts index ded52232..bd9d3166 100644 --- a/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts +++ b/src/features/tmux-installer/main/infrastructure/runtime/__tests__/TmuxPlatformCommandExecutor.test.ts @@ -11,6 +11,7 @@ vi.mock('node:child_process', async () => { }); import * as childProcess from 'node:child_process'; +import * as fs from 'node:fs'; import { TmuxPlatformCommandExecutor } from '../TmuxPlatformCommandExecutor'; @@ -28,6 +29,7 @@ const originalWindir = process.env.WINDIR; describe('TmuxPlatformCommandExecutor', () => { beforeEach(() => { vi.resetAllMocks(); + vi.spyOn(fs.promises, 'readdir').mockRejectedValue(new Error('ENOENT')); }); afterEach(() => { @@ -76,7 +78,7 @@ describe('TmuxPlatformCommandExecutor', () => { } as never, {} as never ); - vi.spyOn(executor, 'execTmux').mockResolvedValue({ + const execTmux = vi.spyOn(executor, 'execTmux').mockResolvedValue({ exitCode: 0, stdout: '%1\t111\tzsh\t/tmp\tteam\tmain\n%2\t222\tnode\t/project\tteam\tworker\n%3\tnot-a-pid\tzsh\t/tmp\tteam\tmain\n', @@ -86,14 +88,15 @@ describe('TmuxPlatformCommandExecutor', () => { await expect(executor.listPanePids(['%2', '%3', '%2'])).resolves.toEqual( new Map([['%2', 222]]) ); - expect(executor.execTmux).toHaveBeenCalledWith( + expect(execTmux).toHaveBeenCalledWith( [ 'list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}\t#{pane_current_command}\t#{pane_current_path}\t#{session_name}\t#{window_name}', ], - 3_000 + 3_000, + undefined ); }); diff --git a/src/main/index.ts b/src/main/index.ts index e63de9a8..8e8a9e39 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -1079,7 +1079,7 @@ async function initializeServices(): Promise { new TeamTaskStallSnapshotSource(), new TeamTaskStallPolicy(), new TeamTaskStallJournal(), - new TeamTaskStallNotifier(teamDataService) + new TeamTaskStallNotifier(teamDataService, teamProvisioningService) ); let teammateToolTracker: TeammateToolTracker | null = null; branchStatusService = new BranchStatusService((event) => { diff --git a/src/main/ipc/teams.ts b/src/main/ipc/teams.ts index 02176f9a..d8ff4d8b 100644 --- a/src/main/ipc/teams.ts +++ b/src/main/ipc/teams.ts @@ -3,6 +3,7 @@ import { setCurrentMainOp } from '@main/services/infrastructure/EventLoopLagMoni import { getTeamDataWorkerClient } from '@main/services/team/TeamDataWorkerClient'; import { getAppIconPath } from '@main/utils/appIcon'; import { getAppDataPath, getTeamsBasePath } from '@main/utils/pathDecoder'; +import { safeSendToRenderer } from '@main/utils/safeWebContentsSend'; import { stripMarkdown } from '@main/utils/textFormatting'; import { TEAM_ADD_MEMBER, @@ -11,8 +12,8 @@ import { TEAM_ALIVE_LIST, TEAM_CANCEL_PROVISIONING, TEAM_CREATE, - TEAM_CREATE_INITIAL_GIT_COMMIT, TEAM_CREATE_CONFIG, + TEAM_CREATE_INITIAL_GIT_COMMIT, TEAM_CREATE_TASK, TEAM_DELETE_DRAFT, TEAM_DELETE_TASK_ATTACHMENT, @@ -209,8 +210,8 @@ import type { TeamTask, TeamTaskStatus, TeamUpdateConfigRequest, - TeamWorktreeGitStatus, TeamViewSnapshot, + TeamWorktreeGitStatus, ToolApprovalFileContent, ToolApprovalSettings, UpdateKanbanPatch, @@ -1761,6 +1762,13 @@ async function handleGetClaudeLogs( }); } +function sendProvisioningProgress( + targetWindow: BrowserWindow | null, + progress: TeamProvisioningProgress +): void { + safeSendToRenderer(targetWindow, TEAM_PROVISIONING_PROGRESS, progress); +} + async function handleCreateTeam( event: IpcMainInvokeEvent, request: unknown @@ -1769,16 +1777,12 @@ async function handleCreateTeam( if (!validation.valid) { return { success: false, error: validation.error }; } + const progressTargetWindow = BrowserWindow.fromWebContents(event.sender); return wrapTeamHandler('create', () => { addMainBreadcrumb('team', 'create', { teamName: validation.value.teamName }); return getTeamProvisioningService().createTeam(validation.value, (progress) => { - try { - event.sender.send(TEAM_PROVISIONING_PROGRESS, progress); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.warn(`Failed to emit provisioning progress: ${message}`); - } + sendProvisioningProgress(progressTargetWindow, progress); }); }); } @@ -1790,6 +1794,7 @@ async function handleLaunchTeam( if (!request || typeof request !== 'object') { return { success: false, error: 'Invalid team launch request' }; } + const progressTargetWindow = BrowserWindow.fromWebContents(event.sender); const payload = request as Partial; const validatedTeamName = validateTeamName(payload.teamName); @@ -1912,12 +1917,7 @@ async function handleLaunchTeam( return wrapTeamHandler('create', () => getTeamProvisioningService().createTeam(createRequest, (progress) => { - try { - event.sender.send(TEAM_PROVISIONING_PROGRESS, progress); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.warn(`Failed to emit draft launch provisioning progress: ${message}`); - } + sendProvisioningProgress(progressTargetWindow, progress); }) ); } @@ -1985,12 +1985,7 @@ async function handleLaunchTeam( : undefined, }, (progress) => { - try { - event.sender.send(TEAM_PROVISIONING_PROGRESS, progress); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - logger.warn(`Failed to emit launch provisioning progress: ${message}`); - } + sendProvisioningProgress(progressTargetWindow, progress); } ); }); @@ -4739,6 +4734,8 @@ async function handleGetSavedRequest( name: m.name, role: m.role, workflow: m.workflow, + isolation: m.isolation, + cwd: m.cwd, providerId: m.providerId, model: m.model, effort: m.effort, diff --git a/src/main/services/analysis/SubagentDetailBuilder.ts b/src/main/services/analysis/SubagentDetailBuilder.ts index 93b6c192..c6fc54d9 100644 --- a/src/main/services/analysis/SubagentDetailBuilder.ts +++ b/src/main/services/analysis/SubagentDetailBuilder.ts @@ -18,9 +18,10 @@ import { countTokens } from '@main/utils/tokenizer'; import { createLogger } from '@shared/utils/logger'; import * as path from 'path'; -import { buildSemanticStepGroups } from './SemanticStepGrouper'; import { resolveProjectStorageDir } from '../discovery/projectStorageDir'; +import { buildSemanticStepGroups } from './SemanticStepGrouper'; + import type { SubagentResolver } from '../discovery/SubagentResolver'; import type { FileSystemProvider } from '../infrastructure/FileSystemProvider'; import type { SessionParser } from '../parsing/SessionParser'; diff --git a/src/main/services/discovery/SessionSearcher.ts b/src/main/services/discovery/SessionSearcher.ts index 4bb72868..b51cf9d0 100644 --- a/src/main/services/discovery/SessionSearcher.ts +++ b/src/main/services/discovery/SessionSearcher.ts @@ -19,9 +19,9 @@ import * as path from 'path'; import { startMainSpan } from '../../sentry'; +import { resolveProjectStorageDir } from './projectStorageDir'; import { SearchTextCache } from './SearchTextCache'; import { extractSearchableEntries } from './SearchTextExtractor'; -import { resolveProjectStorageDir } from './projectStorageDir'; import { subprojectRegistry } from './SubprojectRegistry'; import type { SearchableEntry } from './SearchTextExtractor'; diff --git a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts index 7ac04e11..863bfbfd 100644 --- a/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts +++ b/src/main/services/infrastructure/codexAppServer/__tests__/CodexBinaryResolver.test.ts @@ -1,10 +1,11 @@ // @vitest-environment node import { constants as fsConstants } from 'node:fs'; -import type { PathLike } from 'node:fs'; import path from 'node:path'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import type { PathLike } from 'node:fs'; + const accessMock = vi.fn<(filePath: PathLike, mode?: number) => Promise>(); vi.mock('node:fs/promises', () => ({ diff --git a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts index 85c26aa4..44b7ca31 100644 --- a/src/main/services/runtime/ClaudeMultimodelBridgeService.ts +++ b/src/main/services/runtime/ClaudeMultimodelBridgeService.ts @@ -353,6 +353,26 @@ function createDefaultProviderStatus(providerId: CliProviderId): CliProviderStat }; } +function createPendingProviderStatus(providerId: CliProviderId): CliProviderStatus { + return { + ...createDefaultProviderStatus(providerId), + statusMessage: 'Checking...', + }; +} + +function createRuntimeStatusErrorProviderStatus( + providerId: CliProviderId, + error: unknown +): CliProviderStatus { + const message = error instanceof Error ? error.message : String(error); + return { + ...createDefaultProviderStatus(providerId), + verificationState: 'error', + statusMessage: 'Provider status unavailable', + detailMessage: message, + }; +} + function mapRuntimeExtensionCapabilities( providerId: CliProviderId, capabilities?: RuntimeExtensionCapabilitiesResponse @@ -668,6 +688,97 @@ export class ClaudeMultimodelBridgeService { return providers.map((provider) => this.applyConnectionIssue(provider, connectionIssues)); } + private buildProviderStatusesSnapshot( + providers: Map + ): CliProviderStatus[] { + return ORDERED_PROVIDER_IDS.map( + (providerId) => providers.get(providerId) ?? createPendingProviderStatus(providerId) + ); + } + + private async getProviderStatusFromRuntimeStatusCommand( + binaryPath: string, + providerId: CliProviderId, + env: NodeJS.ProcessEnv, + connectionIssues: Partial> + ): Promise { + const { stdout } = await execCli( + binaryPath, + ['runtime', 'status', '--json', '--provider', providerId], + { + timeout: PROVIDER_STATUS_TIMEOUT_MS, + env, + } + ); + const parsed = extractJsonObject(stdout); + return providerConnectionService.enrichProviderStatus( + this.applyConnectionIssue( + this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]), + connectionIssues + ) + ); + } + + private async getProviderStatusFromScopedRuntimeStatus( + binaryPath: string, + providerId: CliProviderId + ): Promise { + const { env, connectionIssues } = await this.buildProviderCliEnv(binaryPath, providerId); + return this.getProviderStatusFromRuntimeStatusCommand( + binaryPath, + providerId, + env, + connectionIssues + ); + } + + private async getProviderStatusesFromScopedRuntimeStatus( + binaryPath: string, + onUpdate?: (providers: CliProviderStatus[]) => void + ): Promise { + const providers = new Map( + ORDERED_PROVIDER_IDS.map((providerId) => [ + providerId, + createPendingProviderStatus(providerId), + ]) + ); + const failures: { providerId: CliProviderId; error: unknown }[] = []; + + await Promise.all( + ORDERED_PROVIDER_IDS.map(async (providerId) => { + try { + providers.set( + providerId, + await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId) + ); + onUpdate?.(this.buildProviderStatusesSnapshot(providers)); + } catch (error) { + failures.push({ providerId, error }); + } + }) + ); + + if (failures.length === 0) { + return this.buildProviderStatusesSnapshot(providers); + } + + if (failures.length === ORDERED_PROVIDER_IDS.length) { + return null; + } + + logger.warn( + `Provider-scoped runtime status failed for ${failures + .map(({ providerId }) => providerId) + .join(', ')}; using partial provider statuses` + ); + + for (const { providerId, error } of failures) { + providers.set(providerId, createRuntimeStatusErrorProviderStatus(providerId, error)); + } + onUpdate?.(this.buildProviderStatusesSnapshot(providers)); + return this.buildProviderStatusesSnapshot(providers); + } + private async getOpenCodeVerifySnapshot( binaryPath: string ): Promise { @@ -761,24 +872,9 @@ export class ClaudeMultimodelBridgeService { providerId: CliProviderId ): Promise { await resolveInteractiveShellEnv(); - const { env, connectionIssues } = await this.buildCliEnv(binaryPath); try { - const { stdout } = await execCli( - binaryPath, - ['runtime', 'status', '--json', '--provider', providerId], - { - timeout: PROVIDER_STATUS_TIMEOUT_MS, - env, - } - ); - const parsed = extractJsonObject(stdout); - return providerConnectionService.enrichProviderStatus( - this.applyConnectionIssue( - this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]), - connectionIssues - ) - ); + return await this.getProviderStatusFromScopedRuntimeStatus(binaryPath, providerId); } catch (error) { if (!this.isUnifiedRuntimeUnsupported(error)) { logger.warn( @@ -937,6 +1033,20 @@ export class ClaudeMultimodelBridgeService { onUpdate?: (providers: CliProviderStatus[]) => void ): Promise { await resolveInteractiveShellEnv(); + + try { + const providers = await this.getProviderStatusesFromScopedRuntimeStatus(binaryPath, onUpdate); + if (providers) { + return providers; + } + } catch (error) { + logger.warn( + `Provider-scoped runtime status unavailable, falling back to full probe: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + const { env, connectionIssues } = await this.buildCliEnv(binaryPath); try { diff --git a/src/main/services/runtime/ProviderConnectionService.ts b/src/main/services/runtime/ProviderConnectionService.ts index 3c4a2abe..de23f11c 100644 --- a/src/main/services/runtime/ProviderConnectionService.ts +++ b/src/main/services/runtime/ProviderConnectionService.ts @@ -2,6 +2,10 @@ import path from 'node:path'; import { evaluateCodexLaunchReadiness } from '@features/codex-account'; import { getCachedShellEnv } from '@main/utils/shellEnv'; +import { + isDynamicCodexModelCatalog, + isUsableCodexModelCatalog, +} from '@shared/utils/codexModelCatalog'; import { ApiKeyService } from '../extensions/apikeys/ApiKeyService'; import { ConfigManager } from '../infrastructure/ConfigManager'; @@ -62,6 +66,8 @@ const PROVIDER_API_KEY_ENV_VARS: Partial> = { }; const CODEX_NATIVE_API_KEY_ENV_VAR = 'CODEX_API_KEY'; +const CODEX_CLI_PATH_ENV_VAR = 'CODEX_CLI_PATH'; +const CODEX_HOME_ENV_VAR = 'CODEX_HOME'; const CODEX_NATIVE_BACKEND_ID = 'codex-native'; function isCodexExecBinary(binaryPath?: string | null): boolean { @@ -85,6 +91,21 @@ function buildCodexForcedLoginLaunchArgs( return ['--settings', JSON.stringify({ codex: { forced_login_method: loginMethod } })]; } +function applyCodexRuntimeContextEnv( + env: NodeJS.ProcessEnv, + snapshot: CodexAccountSnapshotDto +): void { + const binaryPath = snapshot.runtimeContext?.binaryPath?.trim(); + if (binaryPath) { + env[CODEX_CLI_PATH_ENV_VAR] = binaryPath; + } + + const codexHome = snapshot.runtimeContext?.codexHome?.trim(); + if (codexHome) { + env[CODEX_HOME_ENV_VAR] = codexHome; + } +} + export class ProviderConnectionService { private static instance: ProviderConnectionService | null = null; private codexAccountFeature: Pick | null = null; @@ -179,6 +200,7 @@ export class ProviderConnectionService { } const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + applyCodexRuntimeContextEnv(env, snapshot); const readiness = evaluateCodexLaunchReadiness({ preferredAuthMode: snapshot.preferredAuthMode, managedAccount: snapshot.managedAccount, @@ -239,6 +261,7 @@ export class ProviderConnectionService { } const snapshot = this.mergeCodexApiKeyAvailability(await this.getCodexAccountSnapshot(), env); + applyCodexRuntimeContextEnv(env, snapshot); const readiness = evaluateCodexLaunchReadiness({ preferredAuthMode: snapshot.preferredAuthMode, managedAccount: snapshot.managedAccount, @@ -400,12 +423,21 @@ export class ProviderConnectionService { connection: await this.getConnectionInfo(provider.providerId), }; - if (provider.providerId !== 'codex' || !this.codexModelCatalogFeature) { + if (provider.providerId !== 'codex') { return withConnection; } try { - const catalog = await this.codexModelCatalogFeature.getCatalog(); + const orchestratorCatalog = isUsableCodexModelCatalog(withConnection.modelCatalog) + ? withConnection.modelCatalog + : null; + const catalog = + orchestratorCatalog ?? + (this.codexModelCatalogFeature ? await this.codexModelCatalogFeature.getCatalog() : null); + if (!isUsableCodexModelCatalog(catalog)) { + return withConnection; + } + const models = catalog.models .filter((model) => !model.hidden) .map((model) => model.launchModel.trim()) @@ -419,16 +451,20 @@ export class ProviderConnectionService { ); const runtimeReasoningCapability = withConnection.runtimeCapabilities?.reasoningEffort; const runtimeModelCatalogCapability = withConnection.runtimeCapabilities?.modelCatalog; + const modelCatalogCapability = + orchestratorCatalog && runtimeModelCatalogCapability + ? runtimeModelCatalogCapability + : { + dynamic: isDynamicCodexModelCatalog(catalog), + source: catalog.source, + }; return { ...withConnection, models: models.length > 0 ? models : withConnection.models, modelCatalog: catalog, runtimeCapabilities: { ...withConnection.runtimeCapabilities, - modelCatalog: { - dynamic: runtimeModelCatalogCapability?.dynamic === true, - source: catalog.source, - }, + modelCatalog: modelCatalogCapability, reasoningEffort: { supported: runtimeReasoningCapability?.supported ?? reasoningEfforts.length > 0, values: @@ -566,6 +602,10 @@ export class ProviderConnectionService { requiresOpenaiAuth: null, localAccountArtifactsPresent: false, localActiveChatgptAccountPresent: false, + runtimeContext: { + binaryPath: null, + codexHome: null, + }, login: { status: 'idle', error: null, diff --git a/src/main/services/runtime/cliSettingsArgs.ts b/src/main/services/runtime/cliSettingsArgs.ts new file mode 100644 index 00000000..fb7dc940 --- /dev/null +++ b/src/main/services/runtime/cliSettingsArgs.ts @@ -0,0 +1,83 @@ +type JsonObject = Record; + +function isJsonObject(value: unknown): value is JsonObject { + return typeof value === 'object' && value !== null && !Array.isArray(value); +} + +function parseJsonSettingsObject(raw: string): JsonObject | null { + try { + const parsed = JSON.parse(raw) as unknown; + return isJsonObject(parsed) ? parsed : null; + } catch { + return null; + } +} + +function deepMergeJsonObjects(target: JsonObject, source: JsonObject): JsonObject { + const merged: JsonObject = { ...target }; + for (const [key, value] of Object.entries(source)) { + const current = merged[key]; + if (isJsonObject(current) && isJsonObject(value)) { + merged[key] = deepMergeJsonObjects(current, value); + continue; + } + merged[key] = value; + } + return merged; +} + +/** + * Native multimodel launches may receive app settings and provider settings as + * separate --settings JSON values. Some runtimes read only the first one, so + * collapse parseable JSON settings into one object before spawn. + */ +export function mergeJsonSettingsArgs(args: string[]): string[] { + let mergedSettings: JsonObject | null = null; + let firstSettingsIndex: number | null = null; + const output: string[] = []; + + let i = 0; + while (i < args.length) { + const arg = args[i]; + + if (arg === '--settings') { + const value = args[i + 1]; + if (typeof value === 'string') { + const parsed = parseJsonSettingsObject(value); + if (parsed) { + if (firstSettingsIndex === null) { + firstSettingsIndex = output.length; + } + mergedSettings = deepMergeJsonObjects(mergedSettings ?? {}, parsed); + i += 2; + continue; + } + } + output.push(arg); + i += 1; + continue; + } + + const settingsPrefix = '--settings='; + if (arg.startsWith(settingsPrefix)) { + const parsed = parseJsonSettingsObject(arg.slice(settingsPrefix.length)); + if (parsed) { + if (firstSettingsIndex === null) { + firstSettingsIndex = output.length; + } + mergedSettings = deepMergeJsonObjects(mergedSettings ?? {}, parsed); + i += 1; + continue; + } + } + + output.push(arg); + i += 1; + } + + if (firstSettingsIndex !== null && mergedSettings) { + output.splice(firstSettingsIndex, 0, '--settings', JSON.stringify(mergedSettings)); + } + + return output; +} diff --git a/src/main/services/schedule/ScheduledTaskExecutor.ts b/src/main/services/schedule/ScheduledTaskExecutor.ts index 2d04bb54..34cd0761 100644 --- a/src/main/services/schedule/ScheduledTaskExecutor.ts +++ b/src/main/services/schedule/ScheduledTaskExecutor.ts @@ -14,6 +14,7 @@ import { resolveInteractiveShellEnv } from '@main/utils/shellEnv'; import { createLogger } from '@shared/utils/logger'; import { migrateProviderBackendId } from '@shared/utils/providerBackend'; +import { mergeJsonSettingsArgs } from '../runtime/cliSettingsArgs'; import { buildProviderAwareCliEnv } from '../runtime/providerAwareCliEnv'; import { ClaudeBinaryResolver } from '../team/ClaudeBinaryResolver'; @@ -149,8 +150,6 @@ export class ScheduledTaskExecutor { const args = this.buildArgs(request); - logger.info(`[${request.runId}] Spawning: ${binaryPath} ${args.join(' ')}`); - const providerId = request.config.providerId === 'codex' || request.config.providerId === 'gemini' ? request.config.providerId @@ -171,8 +170,11 @@ export class ScheduledTaskExecutor { } args.push(...providerArgs); + const launchArgs = mergeJsonSettingsArgs(args); - const child = spawnCli(binaryPath, args, { + logger.info(`[${request.runId}] Spawning: ${binaryPath} ${launchArgs.join(' ')}`); + + const child = spawnCli(binaryPath, launchArgs, { cwd: request.config.cwd, // shellEnv spread after buildEnrichedEnv ensures freshly-resolved values // take precedence over the cached snapshot inside buildEnrichedEnv. diff --git a/src/main/services/team/ChangeExtractorService.ts b/src/main/services/team/ChangeExtractorService.ts index 6e7a7d86..6a75053d 100644 --- a/src/main/services/team/ChangeExtractorService.ts +++ b/src/main/services/team/ChangeExtractorService.ts @@ -1,5 +1,5 @@ -import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { appendOpenCodeTaskChangeDiag } from '@main/utils/openCodeTaskChangeDiagLog'; +import { getTasksBasePath, getTeamsBasePath } from '@main/utils/pathDecoder'; import { createLogger } from '@shared/utils/logger'; import { resolveTaskChangePresenceFromResult } from '@shared/utils/taskChangePresence'; import { @@ -9,19 +9,18 @@ import { } from '@shared/utils/taskChangeState'; import { createHash } from 'crypto'; import { existsSync } from 'fs'; -import { mkdtemp, readFile, readdir, rm, stat, writeFile } from 'fs/promises'; +import { mkdtemp, readdir, readFile, rm, stat, writeFile } from 'fs/promises'; import * as os from 'os'; import * as path from 'path'; import { JsonTaskChangeSummaryCacheRepository } from './cache/JsonTaskChangeSummaryCacheRepository'; -import { TeamMetaStore } from './TeamMetaStore'; -import { TaskChangeComputer } from './TaskChangeComputer'; -import { TaskChangeLedgerReader } from './TaskChangeLedgerReader'; import { getOpenCodeLaneScopedRuntimeFilePath, getOpenCodeTeamRuntimeDirectory, readOpenCodeRuntimeLaneIndex, } from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; +import { TaskChangeComputer } from './TaskChangeComputer'; +import { TaskChangeLedgerReader } from './TaskChangeLedgerReader'; import { buildTaskChangePresenceDescriptor, computeTaskChangePresenceProjectFingerprint, @@ -34,18 +33,20 @@ import { type TaskChangeTaskMeta, } from './taskChangeWorkerTypes'; import { TeamConfigReader } from './TeamConfigReader'; +import { TeamMetaStore } from './TeamMetaStore'; import type { TaskChangePresenceRepository } from './cache/TaskChangePresenceRepository'; +import type { OpenCodeLedgerBackfillPort } from './opencode/bridge/OpenCodeReadinessBridge'; +import type { OpenCodePromptDeliveryLedgerRecord } from './opencode/delivery/OpenCodePromptDeliveryLedger'; import type { TaskBoundaryParser } from './TaskBoundaryParser'; import type { TaskChangeWorkerClient } from './TaskChangeWorkerClient'; import type { TeamLogSourceTracker } from './TeamLogSourceTracker'; import type { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; -import type { OpenCodeLedgerBackfillPort } from './opencode/bridge/OpenCodeReadinessBridge'; -import type { OpenCodePromptDeliveryLedgerRecord } from './opencode/delivery/OpenCodePromptDeliveryLedger'; import type { AgentChangeSet, ChangeStats, TaskChangeSetV2 } from '@shared/types'; const logger = createLogger('Service:ChangeExtractorService'); const OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE = 'strict-delivery' as const; +const OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE = 'chain-only' as const; const OPEN_CODE_MAX_DISCOVERED_LANES = 500; /** КСш-запись: Π΄Π°Π½Π½Ρ‹Π΅ + mtime Ρ„Π°ΠΉΠ»Π° + врСмя протухания */ @@ -425,6 +426,7 @@ export class ChangeExtractorService { sourceGeneration, deliveryContextFingerprint, attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, + evidenceMode: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE, }); const now = Date.now(); const cached = this.openCodeBackfillCache.get(cacheKey); @@ -499,6 +501,7 @@ export class ChangeExtractorService { projectDir, workspaceRoot, attributionMode: OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE, + evidenceMode: OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE, ...(deliveryContext.filePath ? { deliveryContextPath: deliveryContext.filePath } : {}), }); void appendOpenCodeTaskChangeDiag({ @@ -678,7 +681,7 @@ export class ChangeExtractorService { teamName: string, taskId: string ): Promise< - Array<{ + { memberName: string; laneId?: string; runtimeSessionId: string | null; @@ -687,8 +690,8 @@ export class ChangeExtractorService { observedAssistantMessageId: string | null; prePromptCursor: string | null; postPromptCursor: string | null; - taskRefs: Array<{ taskId: string; displayId: string; teamName: string }>; - }> + taskRefs: { taskId: string; displayId: string; teamName: string }[]; + }[] > { const teamsBasePath = getTeamsBasePath(); const laneIds = new Set(['primary']); @@ -704,7 +707,7 @@ export class ChangeExtractorService { laneIds.add(laneId); } - const records: Array<{ + const records: { memberName: string; laneId?: string; runtimeSessionId: string | null; @@ -713,8 +716,8 @@ export class ChangeExtractorService { observedAssistantMessageId: string | null; prePromptCursor: string | null; postPromptCursor: string | null; - taskRefs: Array<{ taskId: string; displayId: string; teamName: string }>; - }> = []; + taskRefs: { taskId: string; displayId: string; teamName: string }[]; + }[] = []; for (const laneId of laneIds) { const filePath = getOpenCodeLaneScopedRuntimeFilePath({ @@ -838,6 +841,7 @@ export class ChangeExtractorService { sourceGeneration?: string | null; deliveryContextFingerprint: string; attributionMode: typeof OPEN_CODE_AUTO_BACKFILL_ATTRIBUTION_MODE; + evidenceMode: typeof OPEN_CODE_AUTO_BACKFILL_EVIDENCE_MODE; }): string { return JSON.stringify({ teamName: input.teamName, @@ -848,6 +852,7 @@ export class ChangeExtractorService { sourceGeneration: input.sourceGeneration ?? '', deliveryContextFingerprint: input.deliveryContextFingerprint, attributionMode: input.attributionMode, + evidenceMode: input.evidenceMode, }); } diff --git a/src/main/services/team/ReviewApplierService.ts b/src/main/services/team/ReviewApplierService.ts index 6e8face6..e81214d2 100644 --- a/src/main/services/team/ReviewApplierService.ts +++ b/src/main/services/team/ReviewApplierService.ts @@ -471,6 +471,15 @@ export class ReviewApplierService { ); const relation = this.resolveLedgerRelation(ledgerSnippets); + if (hasUnavailableState) { + return { + handled: true, + status: 'error', + code: 'manual-review-required', + error: 'Ledger content metadata is unavailable; manual review is required.', + }; + } + if (!fullReject) { if (relation?.kind === 'rename' || relation?.kind === 'copy') { return { diff --git a/src/main/services/team/TaskChangeLedgerReader.ts b/src/main/services/team/TaskChangeLedgerReader.ts index 28927f26..f36c7595 100644 --- a/src/main/services/team/TaskChangeLedgerReader.ts +++ b/src/main/services/team/TaskChangeLedgerReader.ts @@ -105,7 +105,8 @@ interface LedgerEvent { | 'powershell_snapshot' | 'post_tool_hook_snapshot' | 'opencode_toolpart_write' - | 'opencode_toolpart_edit'; + | 'opencode_toolpart_edit' + | 'opencode_toolpart_apply_patch'; operation: 'create' | 'modify' | 'delete'; confidence: LedgerConfidence; workspaceRoot: string; @@ -1135,6 +1136,7 @@ export class TaskChangeLedgerReader { case 'notebook_edit': return 'NotebookEdit'; case 'opencode_toolpart_edit': + case 'opencode_toolpart_apply_patch': return 'Edit'; case 'bash_simulated_sed': case 'shell_snapshot': diff --git a/src/main/services/team/TeamLaunchStateEvaluator.ts b/src/main/services/team/TeamLaunchStateEvaluator.ts index a6f65031..9131b462 100644 --- a/src/main/services/team/TeamLaunchStateEvaluator.ts +++ b/src/main/services/team/TeamLaunchStateEvaluator.ts @@ -428,6 +428,9 @@ function normalizePersistedMemberState( bootstrapConfirmed, livenessKind, }); + const hardFailure = skippedForLaunch + ? false + : toBoolean(parsed.hardFailure) || parsed.launchState === 'failed_to_start'; const sources = normalizeSources(parsed.sources) ?? {}; if (!runtimeAlive) { sources.processAlive = undefined; @@ -467,8 +470,8 @@ function normalizePersistedMemberState( agentToolAccepted: skippedForLaunch ? false : toBoolean(parsed.agentToolAccepted), runtimeAlive, bootstrapConfirmed, - hardFailure: skippedForLaunch ? false : toBoolean(parsed.hardFailure), - hardFailureReason: skippedForLaunch + hardFailure, + hardFailureReason: !hardFailure ? undefined : typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0 ? parsed.hardFailureReason.trim() @@ -629,23 +632,22 @@ export function snapshotFromRuntimeMemberStatuses(params: { if (runtime?.livenessSource === 'process' && runtimeAlive) { sources.processAlive = true; } + const launchState = runtime?.launchState ?? 'starting'; + const hardFailure = + runtime?.launchState === 'skipped_for_launch' + ? false + : runtime?.hardFailure === true || launchState === 'failed_to_start'; const entry: PersistedTeamLaunchMemberState = { name, - launchState: runtime?.launchState ?? 'starting', + launchState, skippedForLaunch, skipReason: runtime?.skipReason, skippedAt: runtime?.skippedAt, agentToolAccepted: skippedForLaunch ? false : runtime?.agentToolAccepted === true, runtimeAlive, bootstrapConfirmed: skippedForLaunch ? false : runtime?.bootstrapConfirmed === true, - hardFailure: - runtime?.launchState === 'skipped_for_launch' - ? false - : runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start', - hardFailureReason: - runtime?.launchState === 'skipped_for_launch' - ? undefined - : (runtime?.hardFailureReason ?? runtime?.error), + hardFailure, + hardFailureReason: hardFailure ? (runtime?.hardFailureReason ?? runtime?.error) : undefined, pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length ? [...new Set(runtime.pendingPermissionRequestIds)] : undefined, diff --git a/src/main/services/team/TeamMemberWorktreeManager.ts b/src/main/services/team/TeamMemberWorktreeManager.ts index 849a0d2e..94b613e0 100644 --- a/src/main/services/team/TeamMemberWorktreeManager.ts +++ b/src/main/services/team/TeamMemberWorktreeManager.ts @@ -1,6 +1,6 @@ -import { getClaudeBasePath } from '@main/utils/pathDecoder'; -import { createHash } from 'crypto'; +import { getAppDataPath, getClaudeBasePath } from '@main/utils/pathDecoder'; import { execFile } from 'child_process'; +import { createHash } from 'crypto'; import * as fs from 'fs'; import * as path from 'path'; @@ -101,10 +101,18 @@ export class TeamMemberWorktreeManager { ): Promise { const baseRepoPath = await this.resolveBaseRepoPath(request.baseCwd); const repoHash = shortHash(baseRepoPath); + const projectSlug = slugify(path.basename(baseRepoPath)); const teamSlug = slugify(request.teamName); const memberSlug = slugify(request.memberName); const branchName = `agent-teams/${teamSlug}/${memberSlug}-${repoHash}`; const worktreePath = path.join( + getAppDataPath(), + 'team-worktrees', + `${projectSlug}-${repoHash}`, + teamSlug, + memberSlug + ); + const legacyWorktreePath = path.join( getClaudeBasePath(), 'team-worktrees', repoHash, @@ -121,6 +129,15 @@ export class TeamMemberWorktreeManager { return { baseRepoPath, worktreePath, branchName }; } + const legacyStat = await fs.promises.stat(legacyWorktreePath).catch(() => null); + if (legacyStat) { + if (!legacyStat.isDirectory()) { + throw new Error(`Worktree path exists but is not a directory: ${legacyWorktreePath}`); + } + await this.assertExistingWorktreeMatchesRepo(legacyWorktreePath, baseRepoPath, branchName); + return { baseRepoPath, worktreePath: legacyWorktreePath, branchName }; + } + await fs.promises.mkdir(path.dirname(worktreePath), { recursive: true }); await this.createWorktree({ baseRepoPath, worktreePath, branchName }); return { baseRepoPath, worktreePath, branchName }; diff --git a/src/main/services/team/TeamProvisioningService.ts b/src/main/services/team/TeamProvisioningService.ts index f4a2b031..1c83fb43 100644 --- a/src/main/services/team/TeamProvisioningService.ts +++ b/src/main/services/team/TeamProvisioningService.ts @@ -19,6 +19,7 @@ import { listRuntimeProcessesForCurrentTmuxPlatform, listTmuxPanePidsForCurrentPlatform, listTmuxPaneRuntimeInfoForCurrentPlatform, + sendKeysToTmuxPaneForCurrentPlatform, type TmuxPaneRuntimeInfo, } from '@features/tmux-installer/main'; import { ConfigManager } from '@main/services/infrastructure/ConfigManager'; @@ -68,6 +69,7 @@ import { resolveLanguageName } from '@shared/utils/agentLanguage'; import { resolveAnthropicLaunchModel } from '@shared/utils/anthropicLaunchModel'; import { getAnthropicDefaultTeamModel } from '@shared/utils/anthropicModelDefaults'; import { parseCliArgs } from '@shared/utils/cliArgsParser'; +import { isUsableCodexModelCatalog } from '@shared/utils/codexModelCatalog'; import { deriveContextMetrics, inferContextWindowTokens } from '@shared/utils/contextMetrics'; import { isTeamEffortLevel } from '@shared/utils/effortLevels'; import { getErrorMessage } from '@shared/utils/errorHandling'; @@ -107,6 +109,7 @@ import * as path from 'path'; import pidusage from 'pidusage'; import * as readline from 'readline'; +import { mergeJsonSettingsArgs } from '../runtime/cliSettingsArgs'; import { type GeminiRuntimeAuthState, resolveGeminiRuntimeAuth, @@ -121,7 +124,6 @@ import { } from '../runtime/providerModelProbe'; import { resolveTeamProviderId } from '../runtime/providerRuntimeEnv'; -import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal'; import { createOpenCodePromptDeliveryLedgerStore, hashOpenCodePromptDeliveryPayload, @@ -133,16 +135,17 @@ import { } from './opencode/delivery/OpenCodePromptDeliveryLedger'; import { isOpenCodePromptDeliveryObserveLaterResponseState, - isOpenCodePromptDeliveryRetryAttemptDue, isOpenCodePromptDeliveryRetryableResponseState, - isOpenCodeVisibleReplySemanticallySufficient, + isOpenCodePromptDeliveryRetryAttemptDue, isOpenCodeVisibleReplyReadCommitAllowed, + isOpenCodeVisibleReplySemanticallySufficient, OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS, OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS, OPENCODE_PROMPT_WATCHDOG_GLOBAL_CONCURRENCY, OPENCODE_PROMPT_WATCHDOG_PER_TEAM_CONCURRENCY, type OpenCodeVisibleReplyProof, } from './opencode/delivery/OpenCodePromptDeliveryWatchdog'; +import { createRuntimeDeliveryJournalStore } from './opencode/delivery/RuntimeDeliveryJournal'; import { type RuntimeDeliveryDestinationPort, RuntimeDeliveryDestinationRegistry, @@ -159,6 +162,7 @@ import { readOpenCodeRuntimeLaneIndex, recoverStaleOpenCodeRuntimeLaneIndexEntry, removeOpenCodeRuntimeLaneIndexEntry, + setOpenCodeRuntimeActiveRunManifest, upsertOpenCodeRuntimeLaneIndexEntry, } from './opencode/store/OpenCodeRuntimeManifestEvidenceReader'; import { @@ -181,10 +185,15 @@ import { withInboxLock } from './inboxLock'; import { getEffectiveInboxMessageId } from './inboxMessageIdentity'; import { boundLaunchDiagnostics, - buildProgressAssistantOutput, + buildProgressLiveOutput, buildProgressLogsTail, + buildProgressTraceLine, } from './progressPayload'; -import { resolveDesktopTeammateModeDecision } from './runtimeTeammateMode'; +import { + applyDesktopTeammateModeDecisionToEnv, + buildDesktopTeammateModeCliArgs, + resolveDesktopTeammateModeDecision, +} from './runtimeTeammateMode'; import { choosePreferredLaunchSnapshot, clearBootstrapState, @@ -205,8 +214,8 @@ import { import { TeamLaunchStateStore } from './TeamLaunchStateStore'; import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder'; import { TeamMemberLogsFinder } from './TeamMemberLogsFinder'; -import { TeamMemberWorktreeManager } from './TeamMemberWorktreeManager'; import { TeamMembersMetaStore } from './TeamMembersMetaStore'; +import { TeamMemberWorktreeManager } from './TeamMemberWorktreeManager'; import { TeamMetaStore } from './TeamMetaStore'; import { commandArgEquals, @@ -635,6 +644,60 @@ const STALL_CHECK_INTERVAL_MS = 10_000; const STALL_WARNING_THRESHOLD_MS = 20_000; const APP_TEAM_RUNTIME_DISALLOWED_TOOLS = 'TeamDelete,TodoWrite,TaskCreate,TaskUpdate,mcp__agent-teams__team_launch,mcp__agent-teams__team_stop'; +const DIRECT_TMUX_RESTART_ENV_KEYS = [ + 'CLAUDE_CONFIG_DIR', + 'CLAUDE_TEAM_CONTROL_URL', + 'CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_ENTRY_PROVIDER', + 'CLAUDE_CODE_GEMINI_BACKEND', + 'CLAUDE_CODE_CODEX_BACKEND', + 'ANTHROPIC_BASE_URL', + 'ANTHROPIC_API_KEY', + 'ANTHROPIC_AUTH_TOKEN', + 'GEMINI_BASE_URL', + 'GEMINI_API_VERSION', + 'GEMINI_API_KEY', + 'CODEX_API_KEY', + 'OPENAI_API_KEY', + 'GOOGLE_APPLICATION_CREDENTIALS', + 'GOOGLE_CLOUD_PROJECT', + 'GOOGLE_CLOUD_PROJECT_ID', + 'GCLOUD_PROJECT', + 'HTTPS_PROXY', + 'https_proxy', + 'HTTP_PROXY', + 'http_proxy', + 'NO_PROXY', + 'no_proxy', + 'SSL_CERT_FILE', + 'NODE_EXTRA_CA_CERTS', + 'REQUESTS_CA_BUNDLE', + 'CURL_CA_BUNDLE', +] as const; +const DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS = [ + 'CLAUDE_CODE_USE_OPENAI', + 'CLAUDE_CODE_USE_BEDROCK', + 'CLAUDE_CODE_USE_VERTEX', + 'CLAUDE_CODE_USE_FOUNDRY', + 'CLAUDE_CODE_USE_GEMINI', + 'CLAUDE_CODE_ENTRY_PROVIDER', +] as const; +const INTERACTIVE_SHELL_COMMANDS = new Set([ + 'bash', + 'zsh', + 'sh', + 'fish', + 'nu', + 'pwsh', + 'powershell', + 'cmd', + 'cmd.exe', +]); const TEAM_JSON_READ_TIMEOUT_MS = 5_000; const TEAM_CONFIG_MAX_BYTES = 10 * 1024 * 1024; const TEAM_INBOX_MAX_BYTES = 2 * 1024 * 1024; @@ -702,7 +765,70 @@ function getPreflightTimeoutMs(providerId: TeamProviderId | undefined): number { } function buildProviderCliCommandArgs(providerArgs: string[], args: string[]): string[] { - return [...providerArgs, ...args]; + return mergeJsonSettingsArgs([...providerArgs, ...args]); +} + +function shellQuote(value: string): string { + if (value.length === 0) { + return "''"; + } + return `'${value.replace(/'/g, `'\\''`)}'`; +} + +function isInteractiveShellCommand(command: string | undefined): boolean { + const normalized = command?.trim().toLowerCase(); + if (!normalized) { + return false; + } + return INTERACTIVE_SHELL_COMMANDS.has(path.basename(normalized)); +} + +function getDirectRestartEntryProvider(providerId: TeamProviderId): string { + return providerId === 'codex' || providerId === 'gemini' ? providerId : 'anthropic'; +} + +function buildDirectTmuxRestartEnvAssignments( + env: NodeJS.ProcessEnv, + providerId: TeamProviderId +): string { + const assignments = new Map(); + assignments.set('CLAUDECODE', '1'); + assignments.set('CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS', '1'); + + for (const key of DIRECT_TMUX_RESTART_ENV_KEYS) { + const value = env[key]; + if (typeof value === 'string' && value.length > 0) { + assignments.set(key, value); + } + } + + for (const key of DIRECT_TMUX_PROVIDER_SELECTION_ENV_KEYS) { + assignments.set(key, ''); + } + assignments.set('CLAUDE_CODE_PROVIDER_MANAGED_BY_HOST', '1'); + assignments.set('CLAUDE_CODE_ENTRY_PROVIDER', getDirectRestartEntryProvider(providerId)); + + return [...assignments.entries()].map(([key, value]) => `${key}=${shellQuote(value)}`).join(' '); +} + +function buildDirectTmuxRestartCommand(input: { + cwd: string; + env: NodeJS.ProcessEnv; + providerId: TeamProviderId; + binaryPath: string; + args: string[]; +}): string { + const envAssignments = buildDirectTmuxRestartEnvAssignments(input.env, input.providerId); + const command = [ + 'cd', + shellQuote(input.cwd), + '&&', + 'env', + envAssignments, + shellQuote(input.binaryPath), + ...input.args.map(shellQuote), + ].join(' '); + return `(${command}); __claude_teammate_exit=$?; printf '\\n__CLAUDE_TEAMMATE_EXIT__:%s\\n' "$__claude_teammate_exit"`; } interface ProviderModelListCommandResponse { @@ -797,6 +923,22 @@ function normalizeProviderModelListModels( return models; } +function addModelCatalogLaunchModels( + modelIds: Set, + catalog: CliProviderModelCatalog +): void { + for (const model of catalog.models ?? []) { + const launchModel = model.launchModel?.trim(); + if (launchModel) { + modelIds.add(launchModel); + } + const catalogId = model.id?.trim(); + if (catalogId) { + modelIds.add(catalogId); + } + } +} + function isLegacySafeEffort(effort: EffortLevel): boolean { return effort === 'low' || effort === 'medium' || effort === 'high'; } @@ -1099,6 +1241,9 @@ function buildRuntimeLaunchWarning( if (env.CLAUDE_CODE_CODEX_BACKEND) { flags.push(`CODEX_BACKEND=${env.CLAUDE_CODE_CODEX_BACKEND}`); } + if (env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES === '1') { + flags.push('FORCE_PROCESS_TEAMMATES'); + } const backendPart = backend ? `, backend ${backend}` : ''; const flagsPart = flags.length > 0 ? `, env ${flags.join(', ')}` : ''; const geminiAuth = options?.geminiRuntimeAuth; @@ -1162,6 +1307,7 @@ function logRuntimeLaunchSnapshot( CLAUDE_CODE_ENTRY_PROVIDER: env.CLAUDE_CODE_ENTRY_PROVIDER ?? null, CLAUDE_CODE_GEMINI_BACKEND: env.CLAUDE_CODE_GEMINI_BACKEND ?? null, CLAUDE_CODE_CODEX_BACKEND: env.CLAUDE_CODE_CODEX_BACKEND ?? null, + CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES ?? null, CLAUDE_CONFIG_DIR: env.CLAUDE_CONFIG_DIR ?? null, CLAUDE_TEAM_CONTROL_URL: env.CLAUDE_TEAM_CONTROL_URL ?? null, }, @@ -1338,6 +1484,10 @@ interface ProvisioningRun { pendingInboxRelayCandidates: PendingInboxRelayCandidate[]; /** Accumulates assistant text during provisioning phase for live UI preview. */ provisioningOutputParts: string[]; + /** Bounded orchestration checkpoints shown in the Live output panel. */ + provisioningTraceLines: string[]; + /** Last emitted trace key, used to avoid duplicate progress spam. */ + lastProvisioningTraceKey: string | null; /** Stable assistant message ids -> provisioningOutputParts index for in-place updates. */ provisioningOutputIndexByMessageId: Map; /** Session ID detected from stream-json output (result.session_id or message.session_id). */ @@ -1403,6 +1553,8 @@ interface ProvisioningRun { lastMemberSpawnAuditMissingWarningAt: Map; } +const PROVISIONING_TRACE_STORAGE_LIMIT = 500; + interface MixedSecondaryRuntimeLaneState { laneId: string; providerId: 'opencode'; @@ -1585,6 +1737,103 @@ function isDefinitiveOpenCodePreLaunchFailure( ); } +function hasOpenCodeRuntimeHandle( + value: + | Pick + | Pick + | undefined +): boolean { + if (!value) { + return false; + } + const runtimePid = + typeof value.runtimePid === 'number' && + Number.isFinite(value.runtimePid) && + value.runtimePid > 0; + const runtimeSessionId = (value as { runtimeSessionId?: unknown }).runtimeSessionId; + const runtimeEvidenceSessionId = (value as { sessionId?: unknown }).sessionId; + const sessionId = + (typeof runtimeSessionId === 'string' && runtimeSessionId.trim().length > 0) || + (typeof runtimeEvidenceSessionId === 'string' && runtimeEvidenceSessionId.trim().length > 0); + return runtimePid || sessionId; +} + +function hasOpenCodeRuntimeLivenessMarker( + value: Pick | undefined +): boolean { + return ( + value?.livenessKind === 'runtime_process' || + value?.livenessKind === 'runtime_process_candidate' || + value?.livenessKind === 'permission_blocked' + ); +} + +function hasOpenCodeRuntimeEntryHandle( + value: + | Pick + | undefined + | null +): boolean { + if (!value) { + return false; + } + const pid = typeof value.pid === 'number' && Number.isFinite(value.pid) && value.pid > 0; + const runtimePid = + typeof value.runtimePid === 'number' && + Number.isFinite(value.runtimePid) && + value.runtimePid > 0; + const runtimeSessionId = + typeof value.runtimeSessionId === 'string' && value.runtimeSessionId.trim().length > 0; + return pid || runtimePid || runtimeSessionId || hasOpenCodeRuntimeLivenessMarker(value); +} + +function isRecoverablePersistedOpenCodeRuntimeCandidate( + member: PersistedTeamLaunchMemberState | undefined | null +): boolean { + if (!member || member.skippedForLaunch) { + return false; + } + if ( + member.providerId !== 'opencode' || + member.laneKind !== 'secondary' || + member.laneOwnerProviderId !== 'opencode' || + typeof member.laneId !== 'string' || + member.laneId.trim().length === 0 + ) { + return false; + } + const hasPendingPermission = (member.pendingPermissionRequestIds?.length ?? 0) > 0; + return ( + member.agentToolAccepted === true && (hasOpenCodeRuntimeHandle(member) || hasPendingPermission) + ); +} + +function isRecoverablePersistedOpenCodeTerminalRuntimeCandidate( + member: PersistedTeamLaunchMemberState | undefined | null +): boolean { + return ( + isRecoverablePersistedOpenCodeRuntimeCandidate(member) && + member?.launchState === 'failed_to_start' && + member.hardFailure === true && + hasOpenCodeRuntimeHandle(member) + ); +} + +function isRecoverableOpenCodeRuntimeEvidence( + evidence: TeamRuntimeMemberLaunchEvidence | undefined | null +): evidence is TeamRuntimeMemberLaunchEvidence { + if (!evidence) { + return false; + } + return ( + evidence.runtimeAlive === true || + evidence.bootstrapConfirmed === true || + (evidence.pendingPermissionRequestIds?.length ?? 0) > 0 || + hasOpenCodeRuntimeHandle(evidence) || + (evidence.agentToolAccepted === true && hasOpenCodeRuntimeLivenessMarker(evidence)) + ); +} + function isLaunchGraceWindowFailureReason(reason?: string): boolean { return reason?.trim() === 'Teammate did not join within the launch grace window.'; } @@ -1596,6 +1845,14 @@ function isConfigRegistrationFailureReason(reason?: string): boolean { ); } +function isOpenCodeBridgeLaunchFailureReason(reason?: string): boolean { + return reason?.trim() === 'OpenCode bridge reported member launch failure'; +} + +function isRegisteredRuntimeMetadataFailureReason(reason?: string): boolean { + return reason?.trim() === 'registered runtime metadata without live process'; +} + function isTmuxNoServerRunningError(error: unknown): boolean { const text = error instanceof Error ? error.message : String(error ?? ''); return ( @@ -1608,7 +1865,9 @@ function isAutoClearableLaunchFailureReason(reason?: string): boolean { return ( isNeverSpawnedDuringLaunchReason(reason) || isLaunchGraceWindowFailureReason(reason) || - isConfigRegistrationFailureReason(reason) + isConfigRegistrationFailureReason(reason) || + isRegisteredRuntimeMetadataFailureReason(reason) || + isOpenCodeBridgeLaunchFailureReason(reason) ); } @@ -3362,6 +3621,75 @@ function clearGeminiPostLaunchHydrationState(run: ProvisioningRun): void { run.suppressGeminiPostLaunchHydrationOutput = false; } +function buildProvisioningTraceDetail( + extras?: Pick< + TeamProvisioningProgress, + 'pid' | 'error' | 'warnings' | 'configReady' | 'launchDiagnostics' + > +): string | undefined { + const parts = [ + extras?.pid != null ? `pid=${extras.pid}` : undefined, + extras?.configReady === true ? 'configReady=true' : undefined, + extras?.error ? `error=${extras.error}` : undefined, + extras?.warnings?.length ? `warnings=${extras.warnings.join('; ')}` : undefined, + extras?.launchDiagnostics?.length + ? `launchDiagnostics=${extras.launchDiagnostics.length}` + : undefined, + ].filter((part): part is string => Boolean(part)); + return parts.length > 0 ? parts.join(' | ') : undefined; +} + +function appendProvisioningTrace( + run: ProvisioningRun, + state: Exclude, + message: string, + detail?: string +): void { + run.provisioningTraceLines ??= []; + run.lastProvisioningTraceKey ??= null; + const key = `${state}\u0000${message}\u0000${detail ?? ''}`; + if (run.lastProvisioningTraceKey === key) { + return; + } + run.lastProvisioningTraceKey = key; + run.provisioningTraceLines.push( + buildProgressTraceLine({ + timestamp: nowIso(), + state, + message, + detail, + }) + ); + if (run.provisioningTraceLines.length > PROVISIONING_TRACE_STORAGE_LIMIT) { + run.provisioningTraceLines.splice( + 0, + run.provisioningTraceLines.length - PROVISIONING_TRACE_STORAGE_LIMIT + ); + } +} + +function buildProvisioningLiveOutput(run: ProvisioningRun): string | undefined { + return buildProgressLiveOutput(run.provisioningTraceLines, run.provisioningOutputParts); +} + +function initializeProvisioningTrace(run: ProvisioningRun): void { + appendProvisioningTrace(run, run.progress.state, run.progress.message); + run.progress = { + ...run.progress, + assistantOutput: buildProvisioningLiveOutput(run) ?? run.progress.assistantOutput, + }; +} + +function emitProvisioningCheckpoint(run: ProvisioningRun, message: string, detail?: string): void { + appendProvisioningTrace(run, run.progress.state, message, detail); + run.progress = { + ...run.progress, + updatedAt: nowIso(), + assistantOutput: buildProvisioningLiveOutput(run) ?? run.progress.assistantOutput, + }; + run.onProgress(run.progress); +} + function updateProgress( run: ProvisioningRun, state: Exclude, @@ -3381,8 +3709,8 @@ function updateProgress( // from ~20 event-driven sites (auth retries, stall warnings, spawn events), // and an unbounded `provisioningOutputParts.join` was part of the same OOM // class that `emitLogsProgress` already guards against. - const assistantOutput = - buildProgressAssistantOutput(run.provisioningOutputParts) ?? run.progress.assistantOutput; + appendProvisioningTrace(run, state, message, buildProvisioningTraceDetail(extras)); + const assistantOutput = buildProvisioningLiveOutput(run) ?? run.progress.assistantOutput; run.progress = { ...run.progress, state, @@ -3746,16 +4074,18 @@ function emitLogsProgress(run: ProvisioningRun): void { const logsTail = buildProgressLogsTail(run.claudeLogLines) ?? extractLogsTail(run.stdoutBuffer, run.stderrBuffer); - const assistantOutput = buildProgressAssistantOutput(run.provisioningOutputParts); + const assistantOutput = buildProvisioningLiveOutput(run); + const assistantOutputChanged = + assistantOutput !== undefined && assistantOutput !== run.progress.assistantOutput; - if (!logsTail && !assistantOutput) { + if (!logsTail && !assistantOutputChanged) { return; } run.progress = { ...run.progress, updatedAt: nowIso(), ...(logsTail !== undefined && { cliLogsTail: logsTail }), - ...(assistantOutput !== undefined && { assistantOutput }), + ...(assistantOutputChanged && { assistantOutput }), }; run.onProgress(run.progress); } @@ -3918,6 +4248,8 @@ export class TeamProvisioningService { private readonly provisioningRunByTeam = new Map(); private readonly aliveRunByTeam = new Map(); private readonly runtimeAdapterProgressByRunId = new Map(); + private readonly runtimeAdapterTraceLinesByRunId = new Map(); + private readonly runtimeAdapterTraceKeyByRunId = new Map(); private readonly runtimeAdapterRunByTeam = new Map< string, { @@ -3952,10 +4284,10 @@ export class TeamProvisioningService { Promise >(); private readonly openCodePromptDeliveryWatchdogTimers = new Map(); - private readonly openCodePromptDeliveryWatchdogQueue: Array<{ + private readonly openCodePromptDeliveryWatchdogQueue: { teamName: string; run: () => Promise; - }> = []; + }[] = []; private openCodePromptDeliveryWatchdogInFlight = 0; private openCodePromptDeliveryWatchdogDisabledLogged = false; private readonly openCodePromptDeliveryWatchdogInFlightByTeam = new Map(); @@ -3987,6 +4319,8 @@ export class TeamProvisioningService { private inFlightResponses = new Set(); private runtimeAdapterRegistry: TeamRuntimeAdapterRegistry | null = null; private controlApiBaseUrlResolver: (() => Promise) | null = null; + private readonly stoppedTeamOpenCodeRuntimeCleanupInFlight = new Map>(); + private readonly cleanedStoppedTeamOpenCodeRuntimeLanes = new Set(); private crossTeamSender: | ((request: { fromTeam: string; @@ -4150,38 +4484,22 @@ export class TeamProvisioningService { } if (modelCatalog) { - for (const model of modelCatalog.models ?? []) { - const launchModel = model.launchModel?.trim(); - if (launchModel) { - modelIds.add(launchModel); - } - const catalogId = model.id?.trim(); - if (catalogId) { - modelIds.add(catalogId); - } - } + addModelCatalogLaunchModels(modelIds, modelCatalog); defaultModel = modelCatalog.defaultLaunchModel?.trim() || defaultModel; } - if (params.providerId === 'codex' && runtimeCapabilities?.modelCatalog?.dynamic === true) { + if ( + params.providerId === 'codex' && + !isUsableCodexModelCatalog(modelCatalog) && + runtimeCapabilities?.modelCatalog?.dynamic === true + ) { const codexCatalog = await this.providerConnectionService.getCodexModelCatalog({ cwd: params.cwd, }); - if (codexCatalog?.providerId === 'codex' && codexCatalog.status === 'ready') { - for (const model of codexCatalog.models ?? []) { - const launchModel = model.launchModel?.trim(); - if (launchModel) { - modelIds.add(launchModel); - } - const catalogId = model.id?.trim(); - if (catalogId) { - modelIds.add(catalogId); - } - } + if (isUsableCodexModelCatalog(codexCatalog)) { + addModelCatalogLaunchModels(modelIds, codexCatalog); - if (!modelCatalog) { - modelCatalog = codexCatalog; - } + modelCatalog = codexCatalog; defaultModel = codexCatalog.defaultLaunchModel?.trim() || defaultModel; } } @@ -4536,6 +4854,250 @@ export class TeamProvisioningService { return null; } + private canDeliverToOpenCodeRuntimeForTeam(teamName: string): boolean { + if (this.isTeamAlive(teamName)) { + return true; + } + return this.hasAlivePersistedTeamProcess(teamName); + } + + private hasAlivePersistedTeamProcess(teamName: string): boolean { + const processesPath = path.join(getTeamsBasePath(), teamName, 'processes.json'); + let parsed: unknown; + try { + parsed = JSON.parse(fs.readFileSync(processesPath, 'utf8')) as unknown; + } catch { + return false; + } + if (!Array.isArray(parsed)) { + return false; + } + return parsed.some((row) => { + if (!row || typeof row !== 'object') { + return false; + } + const processRow = row as { pid?: unknown; stoppedAt?: unknown }; + return ( + typeof processRow.pid === 'number' && + Number.isFinite(processRow.pid) && + processRow.stoppedAt == null && + isProcessAlive(processRow.pid) + ); + }); + } + + private cleanupStoppedTeamOpenCodeRuntimeLanesInBackground(teamName: string): void { + void this.stopOpenCodeRuntimeLanesForStoppedTeam(teamName).catch((error) => { + logger.warn( + `[${teamName}] Failed to clean up stopped-team OpenCode runtime lanes: ${ + error instanceof Error ? error.message : String(error) + }` + ); + }); + } + + private stopOpenCodeRuntimeLanesForStoppedTeam(teamName: string): Promise { + const existing = this.stoppedTeamOpenCodeRuntimeCleanupInFlight.get(teamName); + if (existing) { + return existing; + } + const cleanup = this.stopOpenCodeRuntimeLanesForStoppedTeamInternal(teamName).finally(() => { + if (this.stoppedTeamOpenCodeRuntimeCleanupInFlight.get(teamName) === cleanup) { + this.stoppedTeamOpenCodeRuntimeCleanupInFlight.delete(teamName); + } + }); + this.stoppedTeamOpenCodeRuntimeCleanupInFlight.set(teamName, cleanup); + return cleanup; + } + + private async stopOpenCodeRuntimeLanesForStoppedTeamInternal(teamName: string): Promise { + if (this.canDeliverToOpenCodeRuntimeForTeam(teamName)) { + return 0; + } + const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch( + () => null + ); + const activeLaneIds = Object.entries(laneIndex?.lanes ?? {}) + .filter(([, entry]) => entry.state === 'active') + .map(([laneId]) => laneId) + .sort((left, right) => left.localeCompare(right)); + if (activeLaneIds.length === 0) { + return 0; + } + + const adapter = this.getOpenCodeRuntimeAdapter(); + const previousLaunchState = await this.launchStateStore.read(teamName).catch(() => null); + const [config, metaMembers] = await Promise.all([ + this.configReader.getConfig(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + ]); + const evidenceReader = new OpenCodeRuntimeManifestEvidenceReader({ + teamsBasePath: getTeamsBasePath(), + }); + let stopped = 0; + let cleaned = 0; + for (const laneId of activeLaneIds) { + const evidence = await evidenceReader.read(teamName, laneId).catch(() => null); + const runId = evidence?.activeRunId?.trim() || null; + if (adapter && runId) { + try { + await adapter.stop({ + runId, + laneId, + teamName, + cwd: this.resolveOpenCodeRuntimeLaneCleanupCwd(teamName, laneId, config, metaMembers), + providerId: 'opencode', + reason: 'cleanup', + previousLaunchState, + force: true, + }); + stopped += 1; + } catch (error) { + logger.warn( + `[${teamName}] Failed to stop orphaned OpenCode lane ${laneId}: ${ + error instanceof Error ? error.message : String(error) + }` + ); + continue; + } + } else if (runId) { + logger.warn( + `[${teamName}] OpenCode lane ${laneId} belongs to stopped team, but runtime adapter is unavailable.` + ); + continue; + } else if (!runId) { + const pidStopResult = this.tryStopPersistedOpenCodeRuntimePidForStoppedLane({ + teamName, + laneId, + previousLaunchState, + }); + if (pidStopResult === 'unsafe') { + continue; + } + } + + await clearOpenCodeRuntimeLaneStorage({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId, + }).catch(() => undefined); + cleaned += 1; + this.deleteSecondaryRuntimeRun(teamName, laneId); + if (laneId === 'primary') { + this.runtimeAdapterRunByTeam.delete(teamName); + this.aliveRunByTeam.delete(teamName); + this.provisioningRunByTeam.delete(teamName); + } + } + if (cleaned > 0) { + this.cleanedStoppedTeamOpenCodeRuntimeLanes.add(teamName); + } + return stopped; + } + + private tryStopPersistedOpenCodeRuntimePidForStoppedLane(input: { + teamName: string; + laneId: string; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + }): 'stopped' | 'no_pid' | 'unsafe' { + const persistedMember = Object.values(input.previousLaunchState?.members ?? {}).find( + (member) => member.providerId === 'opencode' && member.laneId === input.laneId + ); + if (!persistedMember) { + return 'no_pid'; + } + const pid = persistedMember.runtimePid; + if (typeof pid !== 'number' || !Number.isFinite(pid) || pid <= 0) { + return 'no_pid'; + } + const command = this.readProcessCommandByPid(pid); + if (!command) { + return 'no_pid'; + } + const persistedProcessCommand = (persistedMember as { processCommand?: unknown }) + .processCommand; + const expectedCommand = + typeof persistedProcessCommand === 'string' ? persistedProcessCommand.trim() : ''; + if (expectedCommand && command !== expectedCommand) { + logger.warn( + `[${input.teamName}] Refusing to stop persisted OpenCode pid ${pid} for lane ${input.laneId}: process command changed.` + ); + return 'unsafe'; + } + if (!this.isOpenCodeServeCommand(command)) { + logger.warn( + `[${input.teamName}] Refusing to stop persisted OpenCode pid ${pid} for lane ${input.laneId}: process is not opencode serve.` + ); + return 'unsafe'; + } + try { + killProcessByPid(pid); + logger.info( + `[${input.teamName}] Killed orphaned OpenCode runtime pid=${pid} for stopped lane ${input.laneId}` + ); + return 'stopped'; + } catch (error) { + logger.warn( + `[${input.teamName}] Failed to kill orphaned OpenCode runtime pid=${pid} for stopped lane ${ + input.laneId + }: ${error instanceof Error ? error.message : String(error)}` + ); + return 'unsafe'; + } + } + + private readProcessCommandByPid(pid: number): string | null { + if (process.platform === 'win32') { + try { + return ( + listWindowsProcessTableSync() + .find((row) => row.pid === pid) + ?.command?.trim() || null + ); + } catch { + return null; + } + } + try { + return execFileSync('ps', ['-p', String(pid), '-o', 'command='], { + encoding: 'utf8', + stdio: ['ignore', 'pipe', 'ignore'], + }).trim(); + } catch { + return null; + } + } + + private isOpenCodeServeCommand(command: string): boolean { + return /(^|[/\\\s])opencode(?:\.exe)?(\s|$)/i.test(command) && /\sserve(\s|$)/i.test(command); + } + + private resolveOpenCodeRuntimeLaneCleanupCwd( + teamName: string, + laneId: string, + config: TeamConfig | null, + metaMembers: readonly TeamMember[] + ): string | undefined { + const projectPath = config?.projectPath?.trim() || this.readPersistedTeamProjectPath(teamName); + const memberName = this.extractOpenCodeRuntimeLaneMemberName(laneId); + if (!memberName) { + return projectPath || undefined; + } + const normalized = memberName.toLowerCase(); + const configMember = config?.members?.find( + (member) => member.name?.trim().toLowerCase() === normalized + ); + const metaMember = metaMembers.find( + (member) => member.name?.trim().toLowerCase() === normalized + ); + return metaMember?.cwd?.trim() || configMember?.cwd?.trim() || projectPath || undefined; + } + + private extractOpenCodeRuntimeLaneMemberName(laneId: string): string | null { + const match = /^secondary:opencode:(.+)$/i.exec(laneId.trim()); + return match?.[1]?.trim() || null; + } + private getOpenCodeRuntimeAdapter(): TeamLaunchRuntimeAdapter | null { if (!this.runtimeAdapterRegistry?.has('opencode')) { return null; @@ -4621,7 +5183,41 @@ export class TeamProvisioningService { }); } const hasTaskRefs = (input.taskRefs ?? []).length > 0; - return hasTaskRefs || input.actionMode === 'do' || input.actionMode === 'delegate'; + if (!hasTaskRefs && input.actionMode !== 'do' && input.actionMode !== 'delegate') { + return false; + } + return this.hasOpenCodeNonVisibleProgressProof(input.ledgerRecord); + } + + private hasOpenCodeNonVisibleProgressProof( + ledgerRecord?: OpenCodePromptDeliveryLedgerRecord | null + ): boolean { + const toolNames = ledgerRecord?.observedToolCallNames ?? []; + return toolNames.some((toolName) => { + const normalized = this.normalizeOpenCodeObservedToolName(toolName); + return ( + normalized === 'task_start' || + normalized === 'task_add_comment' || + normalized === 'task_complete' || + normalized === 'task_set_status' || + normalized === 'task_set_clarification' || + normalized === 'task_create' || + normalized === 'task_link' || + normalized === 'runtime_task_event' || + normalized === 'write' || + normalized === 'edit' || + normalized === 'patch' + ); + }); + } + + private normalizeOpenCodeObservedToolName(toolName: string): string { + return toolName + .trim() + .replace(/^mcp__agent[-_]teams__/, '') + .replace(/^agent[-_]teams_/, '') + .replace(/^mcp__agent_teams__/, '') + .replace(/^agent_teams_/, ''); } private isOpenCodePlainTextResponseReadCommitAllowed(input: { @@ -4673,6 +5269,9 @@ export class TeamProvisioningService { if (!hasTaskRefs && input.actionMode !== 'do' && input.actionMode !== 'delegate') { return 'visible_reply_still_required'; } + if (!this.hasOpenCodeNonVisibleProgressProof(record)) { + return 'non_visible_tool_without_task_progress'; + } } if (state === 'empty_assistant_turn') { return 'empty_assistant_turn'; @@ -5103,18 +5702,26 @@ export class TeamProvisioningService { if (!this.isOpenCodePromptDeliveryWatchdogEnabled()) { return 0; } + if (!this.canDeliverToOpenCodeRuntimeForTeam(teamName)) { + await this.stopOpenCodeRuntimeLanesForStoppedTeam(teamName); + return 0; + } const laneIndex = await readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch( () => null ); if (!laneIndex) { return 0; } - return await this.scanOpenCodePromptDeliveryWatchdogForActiveLanes( - teamName, - Object.values(laneIndex.lanes) - .filter((lane) => lane.state === 'active') - .map((lane) => lane.laneId) - ); + let activeLaneIds = Object.values(laneIndex.lanes) + .filter((lane) => lane.state === 'active') + .map((lane) => lane.laneId); + activeLaneIds = [ + ...new Set([ + ...activeLaneIds, + ...(await this.tryRecoverOpenCodeRuntimeLanesForDeliveryWatchdog(teamName)), + ]), + ]; + return await this.scanOpenCodePromptDeliveryWatchdogForActiveLanes(teamName, activeLaneIds); } private async scanOpenCodePromptDeliveryWatchdogForActiveLanes( @@ -5251,7 +5858,6 @@ export class TeamProvisioningService { if (!adapter) { return { delivered: false, reason: 'opencode_runtime_message_bridge_unavailable' }; } - const [config, teamMeta, metaMembers] = await Promise.all([ this.configReader.getConfig(teamName).catch(() => null), this.teamMetaStore.getMeta(teamName).catch(() => null), @@ -5340,7 +5946,7 @@ export class TeamProvisioningService { return { delivered: false, reason: 'opencode_runtime_not_active' }; } } - const runtimeRunId = + let runtimeRunId = laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode' ? (liveSecondaryLaneRunId ?? (await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId))) @@ -5359,7 +5965,35 @@ export class TeamProvisioningService { } runtimeActive = await this.isOpenCodeRuntimeLaneIndexActive(teamName, laneIdentity.laneId); } + if ( + !runtimeActive && + laneIdentity.laneKind === 'secondary' && + laneIdentity.laneOwnerProviderId === 'opencode' + ) { + const recovered = await this.tryRecoverOpenCodeRuntimeLaneBeforeDelivery({ + teamName, + laneId: laneIdentity.laneId, + member: { + ...(configMember ?? {}), + ...(metaMember ?? {}), + name: canonicalMemberName, + providerId: 'opencode', + model: metaMember?.model ?? configMember?.model, + role: metaMember?.role ?? configMember?.role, + workflow: metaMember?.workflow ?? configMember?.workflow, + effort: metaMember?.effort ?? configMember?.effort, + cwd: memberRuntimeCwd || undefined, + isolation: metaMember?.isolation ?? configMember?.isolation, + }, + projectPath: config?.projectPath?.trim() || this.readPersistedTeamProjectPath(teamName), + }); + if (recovered) { + runtimeRunId = await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId); + runtimeActive = true; + } + } if (!runtimeActive) { + this.cleanupStoppedTeamOpenCodeRuntimeLanesInBackground(teamName); return { delivered: false, reason: 'opencode_runtime_not_active' }; } @@ -6206,6 +6840,116 @@ export class TeamProvisioningService { return laneIndex?.lanes[laneId]?.state === 'active'; } + private async tryRecoverOpenCodeRuntimeLaneBeforeDelivery(input: { + teamName: string; + laneId: string; + member: TeamMember; + projectPath: string | null; + }): Promise { + if (!this.canDeliverToOpenCodeRuntimeForTeam(input.teamName)) { + this.cleanupStoppedTeamOpenCodeRuntimeLanesInBackground(input.teamName); + return false; + } + const snapshot = await this.launchStateStore.read(input.teamName).catch(() => null); + const persistedMember = + snapshot?.members?.[input.member.name] ?? + Object.values(snapshot?.members ?? {}).find((member) => member.laneId === input.laneId); + if (!persistedMember || !isRecoverablePersistedOpenCodeRuntimeCandidate(persistedMember)) { + return false; + } + const runtimeEvidence = await this.tryRecoverMissingOpenCodeSecondaryLaneFromRuntime({ + teamName: input.teamName, + laneId: input.laneId, + member: input.member, + projectPath: input.projectPath, + previousLaunchState: snapshot, + persistedMember, + }); + if (!runtimeEvidence) { + return false; + } + logger.info( + `[${input.teamName}] Recovered OpenCode lane ${input.laneId} before message delivery.` + ); + return true; + } + + private async tryRecoverOpenCodeRuntimeLanesForDeliveryWatchdog( + teamName: string + ): Promise { + if (!this.canDeliverToOpenCodeRuntimeForTeam(teamName)) { + this.cleanupStoppedTeamOpenCodeRuntimeLanesInBackground(teamName); + return []; + } + const snapshot = await this.launchStateStore.read(teamName).catch(() => null); + const candidates = Object.values(snapshot?.members ?? {}).filter( + isRecoverablePersistedOpenCodeRuntimeCandidate + ); + if (candidates.length === 0) { + return []; + } + + const [config, teamMeta, metaMembers, currentLaneIndex] = await Promise.all([ + this.configReader.getConfig(teamName).catch(() => null), + this.teamMetaStore.getMeta(teamName).catch(() => null), + this.membersMetaStore.getMembers(teamName).catch(() => []), + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName).catch(() => null), + ]); + const projectPath = config?.projectPath?.trim() || this.readPersistedTeamProjectPath(teamName); + const leadMember = config?.members?.find((member) => isLeadMember(member)); + const leadProviderId = + normalizeOptionalTeamProviderId(teamMeta?.launchIdentity?.providerId) ?? + normalizeOptionalTeamProviderId(teamMeta?.providerId) ?? + normalizeOptionalTeamProviderId(leadMember?.providerId); + const recoveredLaneIds: string[] = []; + for (const persistedMember of candidates) { + const memberName = persistedMember.name.trim(); + const configMember = config?.members?.find( + (member) => member.name?.trim().toLowerCase() === memberName.toLowerCase() + ); + const metaMember = metaMembers.find( + (member) => member.name?.trim().toLowerCase() === memberName.toLowerCase() + ); + if (metaMember?.removedAt != null || configMember?.removedAt != null) { + continue; + } + const laneIdentity = buildPlannedMemberLaneIdentity({ + leadProviderId, + member: { + name: memberName, + providerId: 'opencode', + }, + }); + if (laneIdentity.laneId !== persistedMember.laneId) { + continue; + } + if (currentLaneIndex?.lanes[laneIdentity.laneId]) { + continue; + } + const recovered = await this.tryRecoverOpenCodeRuntimeLaneBeforeDelivery({ + teamName, + laneId: laneIdentity.laneId, + member: { + ...(configMember ?? {}), + ...(metaMember ?? {}), + name: memberName, + providerId: 'opencode', + model: metaMember?.model ?? configMember?.model ?? persistedMember.model, + role: metaMember?.role ?? configMember?.role, + workflow: metaMember?.workflow ?? configMember?.workflow, + effort: metaMember?.effort ?? configMember?.effort ?? persistedMember.effort, + cwd: metaMember?.cwd ?? configMember?.cwd ?? persistedMember.cwd, + isolation: metaMember?.isolation ?? configMember?.isolation, + }, + projectPath, + }); + if (recovered) { + recoveredLaneIds.push(laneIdentity.laneId); + } + } + return [...new Set(recoveredLaneIds)]; + } + private async resolveOpenCodeRuntimeLaneId(params: { teamName: string; runId: string; @@ -6293,13 +7037,41 @@ export class TeamProvisioningService { ); } + private enrichRuntimeAdapterProgressTrace( + progress: TeamProvisioningProgress + ): TeamProvisioningProgress { + const detail = buildProvisioningTraceDetail(progress); + const key = `${progress.state}\u0000${progress.message}\u0000${detail ?? ''}`; + const lines = this.runtimeAdapterTraceLinesByRunId.get(progress.runId) ?? []; + if (this.runtimeAdapterTraceKeyByRunId.get(progress.runId) !== key) { + this.runtimeAdapterTraceKeyByRunId.set(progress.runId, key); + lines.push( + buildProgressTraceLine({ + timestamp: progress.updatedAt, + state: progress.state, + message: progress.message, + detail, + }) + ); + if (lines.length > PROVISIONING_TRACE_STORAGE_LIMIT) { + lines.splice(0, lines.length - PROVISIONING_TRACE_STORAGE_LIMIT); + } + this.runtimeAdapterTraceLinesByRunId.set(progress.runId, lines); + } + return { + ...progress, + assistantOutput: buildProgressLiveOutput(lines, []) ?? progress.assistantOutput, + }; + } + private setRuntimeAdapterProgress( progress: TeamProvisioningProgress, onProgress?: (progress: TeamProvisioningProgress) => void ): TeamProvisioningProgress { - this.runtimeAdapterProgressByRunId.set(progress.runId, progress); - onProgress?.(progress); - return progress; + const nextProgress = this.enrichRuntimeAdapterProgressTrace(progress); + this.runtimeAdapterProgressByRunId.set(nextProgress.runId, nextProgress); + onProgress?.(nextProgress); + return nextProgress; } private async getPersistedTranscriptClaudeLogs( @@ -8884,6 +9656,282 @@ export class TeamProvisioningService { return snapshot; } + private getDirectTmuxRestartPaneId( + persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[], + memberName: string + ): string | null { + for (const persistedRuntimeMember of persistedRuntimeMembers) { + const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase(); + const paneId = + typeof persistedRuntimeMember.tmuxPaneId === 'string' + ? persistedRuntimeMember.tmuxPaneId.trim() + : ''; + const runtimeMemberName = + typeof persistedRuntimeMember.name === 'string' ? persistedRuntimeMember.name : ''; + if ( + backendType === 'tmux' && + paneId && + matchesMemberNameOrBase(runtimeMemberName, memberName) + ) { + return paneId; + } + } + return null; + } + + private resolveDirectRestartRuntimeCwd(params: { + configuredMember: NonNullable< + ReturnType + >; + persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[]; + config: TeamConfig; + run: ProvisioningRun; + }): string { + const configuredCwd = params.configuredMember.cwd?.trim(); + if (configuredCwd) { + return path.resolve(configuredCwd); + } + + for (const runtimeMember of params.persistedRuntimeMembers) { + const cwd = typeof runtimeMember.cwd === 'string' ? runtimeMember.cwd.trim() : ''; + if (cwd) { + return path.resolve(cwd); + } + } + + const projectPath = params.config.projectPath?.trim(); + if (projectPath) { + return path.resolve(projectPath); + } + + const runCwd = this.getRunTrackedCwd(params.run); + if (runCwd) { + return path.resolve(runCwd); + } + + throw new Error('Cannot restart teammate because its runtime cwd is unavailable'); + } + + private async updateDirectTmuxRestartMemberConfig(input: { + teamName: string; + memberName: string; + member: NonNullable>; + agentId: string; + color: string; + prompt: string; + paneId: string; + cwd: string; + providerId: TeamProviderId; + joinedAt: number; + bootstrapExpectedAfter: string; + }): Promise { + const configPath = path.join(getTeamsBasePath(), input.teamName, 'config.json'); + const raw = await tryReadRegularFileUtf8(configPath, { + timeoutMs: TEAM_JSON_READ_TIMEOUT_MS, + maxBytes: TEAM_CONFIG_MAX_BYTES, + }); + if (!raw) { + throw new Error(`Team "${input.teamName}" configuration is no longer available`); + } + + const parsed = JSON.parse(raw) as TeamConfig & { members?: Record[] }; + const members = Array.isArray(parsed.members) ? parsed.members : []; + const existingIndex = members.findIndex((member) => { + const candidateName = typeof member?.name === 'string' ? member.name.trim() : ''; + return ( + candidateName.length > 0 && matchesExactTeamMemberName(candidateName, input.memberName) + ); + }); + const existing: Record = + existingIndex >= 0 ? (members[existingIndex] ?? {}) : {}; + const nextMember = { + ...existing, + agentId: input.agentId, + name: input.member.name, + ...(input.member.role ? { role: input.member.role } : {}), + ...(input.member.workflow ? { workflow: input.member.workflow } : {}), + ...(input.member.agentType ? { agentType: input.member.agentType } : {}), + provider: input.providerId, + providerId: input.providerId, + ...(input.member.model ? { model: input.member.model } : {}), + ...(input.member.effort ? { effort: input.member.effort } : {}), + prompt: input.prompt, + color: input.color, + joinedAt: input.joinedAt, + bootstrapExpectedAfter: input.bootstrapExpectedAfter, + tmuxPaneId: input.paneId, + cwd: input.cwd, + subscriptions: Array.isArray(existing.subscriptions) ? existing.subscriptions : [], + backendType: 'tmux', + }; + + if (existingIndex >= 0) { + members[existingIndex] = nextMember; + } else { + members.push(nextMember); + } + parsed.members = members; + await atomicWriteAsync(configPath, `${JSON.stringify(parsed, null, 2)}\n`); + } + + private enqueueDirectRestartPrompt(input: { + teamName: string; + memberName: string; + leadName: string; + leadSessionId: string | null; + prompt: string; + }): void { + const timestamp = nowIso(); + this.persistInboxMessage(input.teamName, input.memberName, { + from: input.leadName, + to: input.memberName, + text: input.prompt, + timestamp, + read: false, + source: 'system_notification', + leadSessionId: input.leadSessionId ?? undefined, + messageId: `direct-restart-${input.memberName}-${randomUUID()}`, + summary: `Restart bootstrap instructions for ${input.memberName}`, + }); + } + + private async launchDirectTmuxMemberRestart(input: { + run: ProvisioningRun; + teamName: string; + displayName: string; + leadName: string; + memberName: string; + config: TeamConfig; + configuredMember: NonNullable< + ReturnType + >; + persistedRuntimeMembers: readonly PersistedRuntimeMemberLike[]; + paneId: string; + }): Promise { + const paneInfo = (await listTmuxPaneRuntimeInfoForCurrentPlatform([input.paneId])).get( + input.paneId + ); + if (!paneInfo) { + throw new Error( + `Cannot restart teammate "${input.memberName}" because tmux pane ${input.paneId} is not available` + ); + } + if (!isInteractiveShellCommand(paneInfo.currentCommand)) { + throw new Error( + `Cannot restart teammate "${input.memberName}" because tmux pane ${input.paneId} is busy (${paneInfo.currentCommand ?? 'unknown command'})` + ); + } + + const providerId = resolveTeamProviderId(input.configuredMember.providerId); + const claudePath = await ClaudeBinaryResolver.resolve(); + if (!claudePath) { + throw new Error('Claude CLI not found; install it or provide a valid path'); + } + + const cwd = this.resolveDirectRestartRuntimeCwd({ + configuredMember: input.configuredMember, + persistedRuntimeMembers: input.persistedRuntimeMembers, + config: input.config, + run: input.run, + }); + await ensureCwdExists(cwd); + + const provisioningEnv = await this.buildProvisioningEnv( + providerId, + input.configuredMember.providerBackendId + ); + if (provisioningEnv.warning) { + throw new Error(provisioningEnv.warning); + } + + const mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(cwd); + const agentId = `${input.configuredMember.name}@${input.teamName}`; + const color = + input.config.members + ?.find((member) => matchesExactTeamMemberName(member.name, input.memberName)) + ?.color?.trim() || getMemberColorByName(input.configuredMember.name); + const parentSessionId = + input.run.detectedSessionId?.trim() || input.config.leadSessionId?.trim() || input.run.runId; + const prompt = buildMemberSpawnPrompt( + { + name: input.configuredMember.name, + ...(input.configuredMember.role ? { role: input.configuredMember.role } : {}), + ...(input.configuredMember.workflow ? { workflow: input.configuredMember.workflow } : {}), + ...(input.configuredMember.providerId + ? { providerId: input.configuredMember.providerId } + : {}), + ...(input.configuredMember.model ? { model: input.configuredMember.model } : {}), + ...(input.configuredMember.effort ? { effort: input.configuredMember.effort } : {}), + }, + input.displayName, + input.teamName, + input.leadName + ); + const bootstrapExpectedAfter = nowIso(); + + const runtimeArgs = mergeJsonSettingsArgs([ + '--agent-id', + agentId, + '--agent-name', + input.configuredMember.name, + '--team-name', + input.teamName, + '--agent-color', + color, + '--parent-session-id', + parentSessionId, + ...(input.configuredMember.agentType + ? ['--agent-type', input.configuredMember.agentType] + : []), + '--mcp-config', + mcpConfigPath, + '--strict-mcp-config', + '--disallowedTools', + APP_TEAM_RUNTIME_DISALLOWED_TOOLS, + ...(input.run.request.skipPermissions !== false + ? ['--dangerously-skip-permissions', '--permission-mode', 'bypassPermissions'] + : ['--permission-prompt-tool', 'stdio', '--permission-mode', 'default']), + ...(input.configuredMember.model ? ['--model', input.configuredMember.model] : []), + ...(input.configuredMember.effort ? ['--effort', input.configuredMember.effort] : []), + ...(provisioningEnv.providerArgs ?? []), + ]); + const command = buildDirectTmuxRestartCommand({ + cwd, + env: provisioningEnv.env, + providerId, + binaryPath: claudePath, + args: runtimeArgs, + }); + + await this.updateDirectTmuxRestartMemberConfig({ + teamName: input.teamName, + memberName: input.memberName, + member: input.configuredMember, + agentId, + color, + prompt, + paneId: input.paneId, + cwd, + providerId, + joinedAt: Date.now(), + bootstrapExpectedAfter, + }); + this.enqueueDirectRestartPrompt({ + teamName: input.teamName, + memberName: input.configuredMember.name, + leadName: input.leadName, + leadSessionId: parentSessionId, + prompt, + }); + await sendKeysToTmuxPaneForCurrentPlatform(input.paneId, command); + this.appendMemberBootstrapDiagnostic( + input.run, + input.memberName, + `restart command delivered to tmux pane ${input.paneId}` + ); + this.setMemberSpawnStatus(input.run, input.memberName, 'waiting'); + } + async restartMember(teamName: string, memberName: string): Promise { const runId = this.getAliveRunId(teamName); if (!runId) { @@ -8921,8 +9969,9 @@ export class TeamProvisioningService { }; }; - let { config, configuredMembers, metaMembers, configuredMember } = - await readCurrentConfiguredMember(); + let currentConfiguredMemberState = await readCurrentConfiguredMember(); + let config = currentConfiguredMemberState.config; + let configuredMember = currentConfiguredMemberState.configuredMember; if (!config) { throw new Error(`Team "${teamName}" configuration is no longer available`); } @@ -8957,6 +10006,10 @@ export class TeamProvisioningService { const candidateName = typeof member.name === 'string' ? member.name.trim() : ''; return candidateName.length > 0 && matchesMemberNameOrBase(candidateName, memberName); }); + const directTmuxRestartCandidatePaneId = this.getDirectTmuxRestartPaneId( + persistedRuntimeMembers, + memberName + ); const backendTypes = new Set( persistedRuntimeMembers @@ -8993,31 +10046,51 @@ export class TeamProvisioningService { ); } - const tmuxPaneIdsToVerify: string[] = []; - for (const persistedRuntimeMember of persistedRuntimeMembers) { - const paneId = - typeof persistedRuntimeMember.tmuxPaneId === 'string' - ? persistedRuntimeMember.tmuxPaneId.trim() - : ''; - const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase(); - if (!paneId || backendType !== 'tmux') { - continue; - } - tmuxPaneIdsToVerify.push(paneId); + let directTmuxRestartPaneId: string | null = null; + if (directTmuxRestartCandidatePaneId) { try { - killTmuxPaneForCurrentPlatformSync(paneId); - logger.info( - `[${teamName}] Killed teammate pane ${memberName} (${paneId}) for manual restart` - ); + const paneInfo = ( + await listTmuxPaneRuntimeInfoForCurrentPlatform([directTmuxRestartCandidatePaneId]) + ).get(directTmuxRestartCandidatePaneId); + if (paneInfo && isInteractiveShellCommand(paneInfo.currentCommand)) { + directTmuxRestartPaneId = directTmuxRestartCandidatePaneId; + } } catch (error) { logger.debug( - `[${teamName}] Failed to kill teammate pane ${memberName} (${paneId}) for manual restart: ${ + `[${teamName}] Direct tmux restart probe failed for ${memberName}: ${ error instanceof Error ? error.message : String(error) }` ); } } + const tmuxPaneIdsToVerify: string[] = []; + if (!directTmuxRestartPaneId) { + for (const persistedRuntimeMember of persistedRuntimeMembers) { + const paneId = + typeof persistedRuntimeMember.tmuxPaneId === 'string' + ? persistedRuntimeMember.tmuxPaneId.trim() + : ''; + const backendType = persistedRuntimeMember.backendType?.trim().toLowerCase(); + if (!paneId || backendType !== 'tmux') { + continue; + } + tmuxPaneIdsToVerify.push(paneId); + try { + killTmuxPaneForCurrentPlatformSync(paneId); + logger.info( + `[${teamName}] Killed teammate pane ${memberName} (${paneId}) for manual restart` + ); + } catch (error) { + logger.debug( + `[${teamName}] Failed to kill teammate pane ${memberName} (${paneId}) for manual restart: ${ + error instanceof Error ? error.message : String(error) + }` + ); + } + } + } + for (const pid of livePids) { try { killProcessByPid(pid); @@ -9077,8 +10150,9 @@ export class TeamProvisioningService { throw new Error(`Team "${teamName}" is not currently running`); } - ({ config, configuredMembers, metaMembers, configuredMember } = - await readCurrentConfiguredMember()); + currentConfiguredMemberState = await readCurrentConfiguredMember(); + config = currentConfiguredMemberState.config; + configuredMember = currentConfiguredMemberState.configuredMember; if (!config) { throw new Error(`Team "${teamName}" configuration disappeared while restart was in progress`); } @@ -9113,7 +10187,36 @@ export class TeamProvisioningService { }, }); - const leadName = this.resolveLeadMemberName(configuredMembers, metaMembers); + const leadName = this.resolveLeadMemberName( + currentConfiguredMemberState.configuredMembers, + currentConfiguredMemberState.metaMembers + ); + if (directTmuxRestartPaneId) { + try { + await this.launchDirectTmuxMemberRestart({ + run, + teamName, + displayName: config?.name?.trim() || teamName, + leadName, + memberName, + config, + configuredMember, + persistedRuntimeMembers, + paneId: directTmuxRestartPaneId, + }); + return; + } catch (error) { + run.pendingMemberRestarts.delete(memberName); + this.setMemberSpawnStatus( + run, + memberName, + 'error', + error instanceof Error ? error.message : String(error) + ); + throw error; + } + } + const restartMessage = buildRestartMemberSpawnMessage( teamName, config?.name?.trim() || teamName, @@ -9355,6 +10458,20 @@ export class TeamProvisioningService { ); const existingLane = existingLaneIndex >= 0 ? run.mixedSecondaryLanes[existingLaneIndex] : null; + if (run.pendingMemberRestarts.has(memberName)) { + throw new Error(`Restart for teammate "${memberName}" is already in progress`); + } + if (existingLane?.state === 'queued' || existingLane?.state === 'launching') { + throw new Error(`Restart for teammate "${memberName}" is already in progress`); + } + + const hasRuntimeEvidence = await this.hasOpenCodeMemberRuntimeEvidenceForControlledRelaunch({ + teamName, + memberName: memberSpec.name, + laneId: nextLane.laneId, + existingLane, + }); + if (existingLane) { await this.stopSingleMixedSecondaryRuntimeLane(run, existingLane, 'relaunch'); } @@ -9366,7 +10483,10 @@ export class TeamProvisioningService { laneState.state = 'queued'; laneState.result = null; laneState.warnings = []; - laneState.diagnostics = options?.reason ? [`controlled_reattach:${options.reason}`] : []; + laneState.diagnostics = [ + ...(options?.reason ? [`controlled_reattach:${options.reason}`] : []), + ...(!hasRuntimeEvidence ? ['fresh_relaunch:no_runtime_evidence'] : []), + ]; if (existingLaneIndex >= 0) { run.mixedSecondaryLanes[existingLaneIndex] = laneState; @@ -9384,6 +10504,45 @@ export class TeamProvisioningService { await this.launchSingleMixedSecondaryLane(run, laneState); } + private async hasOpenCodeMemberRuntimeEvidenceForControlledRelaunch(params: { + teamName: string; + memberName: string; + laneId: string; + existingLane: MixedSecondaryRuntimeLaneState | null; + }): Promise { + const laneResultMember = + params.existingLane?.result?.members[params.memberName] ?? + Object.values(params.existingLane?.result?.members ?? {}).find( + (member) => member.memberName?.trim() === params.memberName + ); + if (hasOpenCodeRuntimeHandle(laneResultMember)) { + return true; + } + + const persistedSnapshot = await this.launchStateStore.read(params.teamName).catch(() => null); + const persistedMember = + persistedSnapshot?.members[params.memberName] ?? + Object.values(persistedSnapshot?.members ?? {}).find( + (member) => member.laneId === params.laneId + ); + if ( + hasOpenCodeRuntimeHandle(persistedMember) || + hasOpenCodeRuntimeLivenessMarker(persistedMember) + ) { + return true; + } + + const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(params.teamName).catch( + () => new Map() + ); + const liveRuntimeMember = + liveRuntimeByMember.get(params.memberName) ?? + [...liveRuntimeByMember.entries()].find(([candidateName]) => + matchesObservedMemberNameForExpected(candidateName, params.memberName) + )?.[1]; + return hasOpenCodeRuntimeEntryHandle(liveRuntimeMember); + } + async detachOpenCodeOwnedMemberLane(teamName: string, memberName: string): Promise { const run = this.getMutableAliveRunOrThrow(teamName); const laneIndex = run.mixedSecondaryLanes.findIndex((lane) => @@ -11057,9 +12216,7 @@ export class TeamProvisioningService { message: this.buildStallProgressMessage(silenceSec, elapsed), messageSeverity: 'warning' as const, }), - assistantOutput: - buildProgressAssistantOutput(run.provisioningOutputParts) ?? - run.progress.assistantOutput, + assistantOutput: buildProvisioningLiveOutput(run) ?? run.progress.assistantOutput, }; run.onProgress(run.progress); } catch (err) { @@ -11557,6 +12714,7 @@ export class TeamProvisioningService { request: TeamCreateRequest, onProgress: (progress: TeamProvisioningProgress) => void ): Promise { + this.cleanedStoppedTeamOpenCodeRuntimeLanes.delete(request.teamName); const existingProvisioningRunId = this.getProvisioningRunId(request.teamName); if (existingProvisioningRunId) { return { runId: existingProvisioningRunId }; @@ -11691,6 +12849,8 @@ export class TeamProvisioningService { silentUserDmForwardClearHandle: null, pendingInboxRelayCandidates: [], provisioningOutputParts: [], + provisioningTraceLines: [], + lastProvisioningTraceKey: null, provisioningOutputIndexByMessageId: new Map(), detectedSessionId: null, leadActivityState: 'active', @@ -11731,9 +12891,16 @@ export class TeamProvisioningService { this.resetTeamScopedTransientStateForNewRun(request.teamName); this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); + initializeProvisioningTrace(run); run.onProgress(run.progress); + emitProvisioningCheckpoint(run, 'Clearing persisted launch state'); await this.clearPersistedLaunchState(request.teamName); + emitProvisioningCheckpoint( + run, + 'Building deterministic create bootstrap spec', + `expectedMembers=${effectiveMemberSpecs.length}` + ); const bootstrapSpec = buildDeterministicCreateBootstrapSpec( runId, request, @@ -11744,22 +12911,28 @@ export class TeamProvisioningService { let child: ReturnType; shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); - if (teammateModeDecision.forceProcessTeammates) { - shellEnv.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1'; - } + applyDesktopTeammateModeDecisionToEnv(shellEnv, teammateModeDecision); let mcpConfigPath: string; let bootstrapSpecPath: string; let bootstrapUserPromptPath: string | null = null; try { + emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file'); bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec); run.bootstrapSpecPath = bootstrapSpecPath; if (initialUserPrompt) { + emitProvisioningCheckpoint( + run, + 'Writing deferred user prompt file', + `chars=${promptSize.chars} lines=${promptSize.lines}` + ); bootstrapUserPromptPath = await writeDeterministicBootstrapUserPromptFile(initialUserPrompt); run.bootstrapUserPromptPath = bootstrapUserPromptPath; } + emitProvisioningCheckpoint(run, 'Writing MCP config file'); mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); run.mcpConfigPath = mcpConfigPath; + emitProvisioningCheckpoint(run, 'Validating agent-teams MCP runtime'); await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath, { isCancelled: () => run.cancelRequested || @@ -11784,7 +12957,7 @@ export class TeamProvisioningService { ); const resolvedProviderId = resolveTeamProviderId(request.providerId); const providerFastModeArgs = buildProviderFastModeArgs(resolvedProviderId, launchIdentity); - const spawnArgs = [ + const spawnArgs = mergeJsonSettingsArgs([ '--input-format', 'stream-json', '--output-format', @@ -11810,9 +12983,10 @@ export class TeamProvisioningService { ...(launchIdentity.resolvedEffort ? ['--effort', launchIdentity.resolvedEffort] : []), ...providerFastModeArgs, ...(request.worktree ? ['--worktree', request.worktree] : []), + ...buildDesktopTeammateModeCliArgs(teammateModeDecision), ...parseCliArgs(request.extraCliArgs), ...providerArgs, - ]; + ]); const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, promptSize, @@ -11827,6 +13001,7 @@ export class TeamProvisioningService { try { // Pre-save our meta files before spawn β€” CLI doesn't touch these. // If provisioning fails before TeamCreate, user can retry without re-entering config. + emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn'); const teamDir = path.join(getTeamsBasePath(), request.teamName); const tasksDir = path.join(getTasksBasePath(), request.teamName); await fs.promises.mkdir(teamDir, { recursive: true }); @@ -11861,9 +13036,15 @@ export class TeamProvisioningService { throw new Error('Team launch cancelled by app shutdown'); } if (request.skipPermissions === false) { + emitProvisioningCheckpoint(run, 'Seeding lead bootstrap permission rules'); await this.seedLeadBootstrapPermissionRules(request.teamName, request.cwd); } + emitProvisioningCheckpoint( + run, + 'Spawning Claude CLI process', + `args=${spawnArgs.length} cwd=${request.cwd}` + ); child = spawnCli(claudePath, spawnArgs, { cwd: request.cwd, env: { ...shellEnv }, @@ -12178,6 +13359,12 @@ export class TeamProvisioningService { ); try { + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: getTeamsBasePath(), + teamName: input.request.teamName, + laneId: 'primary', + runId, + }); const result = await adapter.launch(launchInput); if ( this.cancelledRuntimeAdapterRunIds.delete(runId) || @@ -12337,6 +13524,7 @@ export class TeamProvisioningService { ): PersistedTeamLaunchMemberState { const now = nowIso(); const launchState = evidence?.launchState ?? 'failed_to_start'; + const hardFailure = evidence?.hardFailure === true || launchState === 'failed_to_start'; return { name: member.name, providerId: 'opencode', @@ -12351,8 +13539,8 @@ export class TeamProvisioningService { agentToolAccepted: evidence?.agentToolAccepted === true, runtimeAlive: evidence?.runtimeAlive === true, bootstrapConfirmed: evidence?.bootstrapConfirmed === true, - hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start', - hardFailureReason: evidence?.hardFailureReason, + hardFailure, + hardFailureReason: hardFailure ? evidence?.hardFailureReason : undefined, pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds?.length ? [...new Set(evidence.pendingPermissionRequestIds)] : undefined, @@ -12739,6 +13927,8 @@ export class TeamProvisioningService { silentUserDmForwardClearHandle: null, pendingInboxRelayCandidates: [], provisioningOutputParts: [], + provisioningTraceLines: [], + lastProvisioningTraceKey: null, provisioningOutputIndexByMessageId: new Map(), detectedSessionId: previousSessionId ?? null, leadActivityState: 'active', @@ -12785,13 +13975,17 @@ export class TeamProvisioningService { this.resetTeamScopedTransientStateForNewRun(request.teamName); this.runs.set(runId, run); this.provisioningRunByTeam.set(request.teamName, runId); + initializeProvisioningTrace(run); run.onProgress(run.progress); + emitProvisioningCheckpoint(run, 'Clearing persisted launch state'); await this.clearPersistedLaunchState(request.teamName); + emitProvisioningCheckpoint(run, 'Publishing mixed secondary lane status'); for (const lane of run.mixedSecondaryLanes ?? []) { await this.publishMixedSecondaryLaneStatusChange(run, lane); } // Read existing tasks to include in teammate prompts for work resumption + emitProvisioningCheckpoint(run, 'Reading existing tasks for launch prompt'); const taskReader = new TeamTaskReader(); let existingTasks: TeamTask[] = []; try { @@ -12812,24 +14006,35 @@ export class TeamProvisioningService { let child: ReturnType; shellEnv.CLAUDE_ENABLE_DETERMINISTIC_TEAM_BOOTSTRAP = '1'; const teammateModeDecision = await resolveDesktopTeammateModeDecision(request.extraCliArgs); - if (teammateModeDecision.forceProcessTeammates) { - shellEnv.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1'; - } + applyDesktopTeammateModeDecisionToEnv(shellEnv, teammateModeDecision); let mcpConfigPath: string; let bootstrapSpecPath: string; let bootstrapUserPromptPath: string | null = null; try { + emitProvisioningCheckpoint( + run, + 'Building deterministic launch bootstrap spec', + `expectedMembers=${effectiveMemberSpecs.length}` + ); const bootstrapSpec = buildDeterministicLaunchBootstrapSpec( runId, request, effectiveMemberSpecs ); + emitProvisioningCheckpoint(run, 'Writing deterministic bootstrap spec file'); bootstrapSpecPath = await writeDeterministicBootstrapSpecFile(bootstrapSpec); run.bootstrapSpecPath = bootstrapSpecPath; + emitProvisioningCheckpoint( + run, + 'Writing launch hydration prompt file', + `chars=${promptSize.chars} lines=${promptSize.lines}` + ); bootstrapUserPromptPath = await writeDeterministicBootstrapUserPromptFile(prompt); run.bootstrapUserPromptPath = bootstrapUserPromptPath; + emitProvisioningCheckpoint(run, 'Writing MCP config file'); mcpConfigPath = await this.mcpConfigBuilder.writeConfigFile(request.cwd); run.mcpConfigPath = mcpConfigPath; + emitProvisioningCheckpoint(run, 'Validating agent-teams MCP runtime'); await this.validateAgentTeamsMcpRuntime(claudePath, request.cwd, shellEnv, mcpConfigPath, { isCancelled: () => run.cancelRequested || @@ -12894,14 +14099,27 @@ export class TeamProvisioningService { if (request.worktree) { launchArgs.push('--worktree', request.worktree); } + launchArgs.push(...buildDesktopTeammateModeCliArgs(teammateModeDecision)); launchArgs.push(...parseCliArgs(request.extraCliArgs)); launchArgs.push(...providerArgs); + // When the lead uses a different provider than some teammates (e.g., anthropic lead + // with codex teammates), the lead needs the teammate provider's launch args so they + // can be inherited by the teammate subprocess via buildInheritedCliFlags. + // Without this, a codex teammate spawned from an anthropic lead has no way to learn + // about the required forced_login_method (chatgpt/api) and fails to start. + emitProvisioningCheckpoint(run, 'Resolving cross-provider member launch args'); + const crossProviderMemberArgs = await this.buildCrossProviderMemberArgs( + resolvedProviderId, + effectiveMemberSpecs + ); + launchArgs.push(...crossProviderMemberArgs); + const finalLaunchArgs = mergeJsonSettingsArgs(launchArgs); const runtimeWarning = buildRuntimeLaunchWarning(request, shellEnv, { geminiRuntimeAuth, promptSize, expectedMembersCount: effectiveMemberSpecs.length, }); - logRuntimeLaunchSnapshot(request.teamName, claudePath, launchArgs, request, shellEnv, { + logRuntimeLaunchSnapshot(request.teamName, claudePath, finalLaunchArgs, request, shellEnv, { geminiRuntimeAuth, promptSize, expectedMembersCount: effectiveMemberSpecs.length, @@ -12909,6 +14127,7 @@ export class TeamProvisioningService { }); // --resume is added above when a valid previous session JSONL exists. // Without it, CLI creates a fresh session ID automatically. + emitProvisioningCheckpoint(run, 'Persisting team metadata before spawn'); await this.teamMetaStore.writeMeta(request.teamName, { displayName: syntheticRequest.displayName, description: syntheticRequest.description, @@ -12944,9 +14163,15 @@ export class TeamProvisioningService { throw new Error('Team launch cancelled by app shutdown'); } if (request.skipPermissions === false) { + emitProvisioningCheckpoint(run, 'Seeding lead bootstrap permission rules'); await this.seedLeadBootstrapPermissionRules(request.teamName, request.cwd); } - child = spawnCli(claudePath, launchArgs, { + emitProvisioningCheckpoint( + run, + 'Spawning Claude CLI process for team launch', + `args=${finalLaunchArgs.length} cwd=${request.cwd}` + ); + child = spawnCli(claudePath, finalLaunchArgs, { cwd: request.cwd, env: { ...shellEnv }, stdio: ['pipe', 'pipe', 'pipe'], @@ -12977,7 +14202,7 @@ export class TeamProvisioningService { run.child = child; run.spawnContext = { claudePath, - args: launchArgs, + args: finalLaunchArgs, cwd: request.cwd, env: { ...shellEnv }, prompt, @@ -13563,7 +14788,7 @@ export class TeamProvisioningService { (config) => config?.members?.find((member) => isLeadMember(member))?.name?.trim() || null ) .catch(() => null); - if (leadName && inboxName.trim().toLowerCase() === leadName.toLowerCase()) { + if (inboxName.trim().toLowerCase() === leadName?.toLowerCase()) { if (await this.isOpenCodeRuntimeRecipient(teamName, inboxName)) { const diagnostic = 'opencode_lead_runtime_session_missing: OpenCode lead inbox relay is unsupported in v1; leaving inbox unread for durable retry/diagnostics.'; @@ -13893,11 +15118,16 @@ export class TeamProvisioningService { ...(result.diagnostics ?? []), ...(delivery.diagnostics ?? [delivery.reason ?? 'opencode_message_delivery_failed']), ]; - logger.warn( - `[${teamName}] OpenCode inbox relay failed for ${memberName}/${message.messageId}: ${ - delivery.reason ?? 'unknown error' - }` - ); + if ( + delivery.reason !== 'opencode_runtime_not_active' || + !this.cleanedStoppedTeamOpenCodeRuntimeLanes.has(teamName) + ) { + logger.warn( + `[${teamName}] OpenCode inbox relay failed for ${memberName}/${message.messageId}: ${ + delivery.reason ?? 'unknown error' + }` + ); + } break; } if (delivery.responsePending) { @@ -15438,8 +16668,9 @@ export class TeamProvisioningService { } const paneIds = [...metadataByMember.values()] + .filter((metadata) => metadata.backendType === 'tmux' || metadata.backendType === undefined) .map((metadata) => metadata.tmuxPaneId?.trim() ?? '') - .filter((paneId) => paneId.length > 0); + .filter((paneId) => paneId.length > 0 && !paneId.startsWith('process:')); let paneInfoById = new Map(); if (paneIds.length > 0) { try { @@ -16022,6 +17253,11 @@ export class TeamProvisioningService { hardFailureReason: evidenceEntry.hardFailureReason, pendingPermissionRequestIds: evidenceEntry.pendingPermissionRequestIds, runtimePid: evidenceEntry.runtimePid, + sessionId: evidenceEntry.sessionId, + livenessKind: evidenceEntry.livenessKind, + pidSource: evidenceEntry.pidSource, + runtimeDiagnostic: evidenceEntry.runtimeDiagnostic, + runtimeDiagnosticSeverity: evidenceEntry.runtimeDiagnosticSeverity, diagnostics: evidenceEntry.diagnostics, } : finishedWithoutRuntimeEvidence @@ -16166,6 +17402,13 @@ export class TeamProvisioningService { private shouldRecoverStalePersistedMixedLaunchSnapshot( snapshot: PersistedTeamLaunchSnapshot ): boolean { + const hasRecoverableOpenCodeRuntimeCandidate = Object.values(snapshot.members).some((member) => + isRecoverablePersistedOpenCodeTerminalRuntimeCandidate(member) + ); + if (hasRecoverableOpenCodeRuntimeCandidate) { + return true; + } + if (snapshot.teamLaunchState !== 'partial_pending') { return false; } @@ -16220,6 +17463,7 @@ export class TeamProvisioningService { run: ProvisioningRun, lane: MixedSecondaryRuntimeLaneState ): Promise { + const requestedDiagnostics = [...lane.diagnostics]; const adapter = this.getOpenCodeRuntimeAdapter(); if (!adapter) { const message = 'OpenCode runtime adapter is not registered for mixed team launch.'; @@ -16243,10 +17487,10 @@ export class TeamProvisioningService { }, }, warnings: [], - diagnostics: [message], + diagnostics: [...requestedDiagnostics, message], }; lane.warnings = []; - lane.diagnostics = [message]; + lane.diagnostics = [...requestedDiagnostics, message]; await this.publishMixedSecondaryLaneStatusChange(run, lane); lane.state = 'finished'; return; @@ -16268,7 +17512,7 @@ export class TeamProvisioningService { lane.state = 'launching'; lane.runId = lane.runId ?? randomUUID(); lane.warnings = []; - lane.diagnostics = [...migration.diagnostics]; + lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics]; const laneCwd = lane.member.cwd?.trim() || run.request.cwd; this.setSecondaryRuntimeRun({ teamName: run.teamName, @@ -16282,6 +17526,12 @@ export class TeamProvisioningService { const previousLaunchState = await this.launchStateStore.read(run.teamName); try { + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: getTeamsBasePath(), + teamName: run.teamName, + laneId: lane.laneId, + runId: lane.runId, + }); const result = await adapter.launch({ runId: lane.runId, laneId: lane.laneId, @@ -16312,10 +17562,11 @@ export class TeamProvisioningService { } lane.result = result; lane.warnings = [...result.warnings]; - lane.diagnostics = [...migration.diagnostics, ...result.diagnostics]; + lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, ...result.diagnostics]; if (isDefinitiveOpenCodePreLaunchFailure(result, lane.member.name)) { const diagnostics = [ + ...requestedDiagnostics, ...migration.diagnostics, ...collectRuntimeLaunchFailureDiagnostics(result, lane.member.name), ]; @@ -16358,7 +17609,7 @@ export class TeamProvisioningService { diagnostics: [message], }; lane.warnings = []; - lane.diagnostics = [...migration.diagnostics, message]; + lane.diagnostics = [...requestedDiagnostics, ...migration.diagnostics, message]; await upsertOpenCodeRuntimeLaneIndexEntry({ teamsBasePath: getTeamsBasePath(), teamName: run.teamName, @@ -16582,6 +17833,12 @@ export class TeamProvisioningService { hardFailureReason?: string; pendingPermissionRequestIds?: string[]; runtimePid?: number; + sessionId?: string; + runtimeSessionId?: string; + livenessKind?: TeamAgentRuntimeLivenessKind; + pidSource?: TeamAgentRuntimePidSource; + runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; diagnostics?: string[]; }; pendingReason?: string; @@ -16606,6 +17863,49 @@ export class TeamProvisioningService { } let laneEntry = laneIndex.lanes[laneIdentity.laneId]; + const persistedMember = + persistedSnapshot?.members?.[member.name] ?? bootstrapSnapshot?.members?.[member.name]; + if ( + !laneEntry && + persistedMember && + isRecoverablePersistedOpenCodeRuntimeCandidate(persistedMember) && + persistedMember.laneId === laneIdentity.laneId + ) { + const runtimeEvidence = await this.tryRecoverMissingOpenCodeSecondaryLaneFromRuntime({ + teamName, + laneId: laneIdentity.laneId, + member, + projectPath, + previousLaunchState: persistedSnapshot ?? bootstrapSnapshot, + persistedMember, + }); + if (runtimeEvidence) { + recoveredAny = true; + secondaryMembers.push({ + laneId: laneIdentity.laneId, + member, + leadDefaults, + evidence: { + launchState: runtimeEvidence.launchState, + agentToolAccepted: runtimeEvidence.agentToolAccepted, + runtimeAlive: runtimeEvidence.runtimeAlive, + bootstrapConfirmed: runtimeEvidence.bootstrapConfirmed, + hardFailure: runtimeEvidence.hardFailure, + hardFailureReason: runtimeEvidence.hardFailureReason, + pendingPermissionRequestIds: runtimeEvidence.pendingPermissionRequestIds, + runtimePid: runtimeEvidence.runtimePid, + sessionId: runtimeEvidence.sessionId, + runtimeSessionId: runtimeEvidence.sessionId, + livenessKind: runtimeEvidence.livenessKind, + pidSource: runtimeEvidence.pidSource, + runtimeDiagnostic: runtimeEvidence.runtimeDiagnostic, + runtimeDiagnosticSeverity: runtimeEvidence.runtimeDiagnosticSeverity, + diagnostics: runtimeEvidence.diagnostics, + }, + }); + continue; + } + } if (laneEntry?.state === 'active') { const runtimeEvidence = await this.tryRecoverActiveOpenCodeSecondaryLaneFromRuntime({ teamName, @@ -16629,6 +17929,11 @@ export class TeamProvisioningService { hardFailureReason: runtimeEvidence.hardFailureReason, pendingPermissionRequestIds: runtimeEvidence.pendingPermissionRequestIds, runtimePid: runtimeEvidence.runtimePid, + sessionId: runtimeEvidence.sessionId, + livenessKind: runtimeEvidence.livenessKind, + pidSource: runtimeEvidence.pidSource, + runtimeDiagnostic: runtimeEvidence.runtimeDiagnostic, + runtimeDiagnosticSeverity: runtimeEvidence.runtimeDiagnosticSeverity, diagnostics: runtimeEvidence.diagnostics, }, }); @@ -16753,6 +18058,61 @@ export class TeamProvisioningService { } } + private async tryRecoverMissingOpenCodeSecondaryLaneFromRuntime(params: { + teamName: string; + laneId: string; + member: TeamMember; + projectPath: string | null; + previousLaunchState: PersistedTeamLaunchSnapshot | null; + persistedMember: PersistedTeamLaunchMemberState; + }): Promise { + const currentLaneIndex = await readOpenCodeRuntimeLaneIndex( + getTeamsBasePath(), + params.teamName + ).catch(() => null); + const currentEntry = currentLaneIndex?.lanes[params.laneId]; + if (currentEntry?.state === 'degraded' || currentEntry?.state === 'stopped') { + return null; + } + if (!isRecoverablePersistedOpenCodeRuntimeCandidate(params.persistedMember)) { + return null; + } + + const runtimeEvidence = await this.tryRecoverActiveOpenCodeSecondaryLaneFromRuntime({ + teamName: params.teamName, + laneId: params.laneId, + member: params.member, + projectPath: params.projectPath, + previousLaunchState: params.previousLaunchState, + }); + if (!isRecoverableOpenCodeRuntimeEvidence(runtimeEvidence)) { + return null; + } + + const diagnostics = Array.from( + new Set([ + 'Recovered missing OpenCode runtime lane index from persisted runtime evidence.', + ...(runtimeEvidence.diagnostics ?? []), + ]) + ); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName: params.teamName, + laneId: params.laneId, + state: 'active', + diagnostics, + }).catch((error: unknown) => { + logger.warn( + `[${params.teamName}] Failed to recover missing OpenCode lane index ${params.laneId}: ${getErrorMessage(error)}` + ); + }); + + return { + ...runtimeEvidence, + diagnostics, + }; + } + private async readLeadInboxMessagesForLaunchReconcile( teamName: string, leadName: string @@ -16816,6 +18176,37 @@ export class TeamProvisioningService { return false; } + private needsBootstrapAcceptanceReconcile( + snapshot: PersistedTeamLaunchSnapshot | null, + bootstrapSnapshot: PersistedTeamLaunchSnapshot | null + ): boolean { + if (!snapshot || !bootstrapSnapshot) { + return false; + } + for (const expected of this.getPersistedLaunchMemberNames(snapshot)) { + const current = snapshot.members[expected]; + const bootstrapMember = bootstrapSnapshot.members[expected]; + if (!current || !bootstrapMember) { + continue; + } + const bootstrapProvesSpawnAcceptance = + bootstrapMember.agentToolAccepted === true || + typeof bootstrapMember.firstSpawnAcceptedAt === 'string'; + if (!bootstrapProvesSpawnAcceptance) { + continue; + } + const currentProvesSpawnAcceptance = + current.agentToolAccepted === true || typeof current.firstSpawnAcceptedAt === 'string'; + if (!currentProvesSpawnAcceptance) { + return true; + } + if (isNeverSpawnedDuringLaunchReason(current.hardFailureReason)) { + return true; + } + } + return false; + } + private async reconcilePersistedLaunchState(teamName: string): Promise<{ snapshot: ReturnType | null; statuses: Record; @@ -16831,8 +18222,15 @@ export class TeamProvisioningService { const filteredRecoveredMixedSnapshot = recoveredMixedSnapshot ? this.filterRemovedMembersFromLaunchSnapshot(recoveredMixedSnapshot, metaMembers) : null; + const filteredBootstrapSnapshot = bootstrapSnapshot + ? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers) + : null; if ( filteredRecoveredMixedSnapshot && + !this.needsBootstrapAcceptanceReconcile( + filteredRecoveredMixedSnapshot, + filteredBootstrapSnapshot + ) && !(await this.hasBootstrapTranscriptLaunchReconcileOutcome(filteredRecoveredMixedSnapshot)) ) { return { @@ -16840,9 +18238,6 @@ export class TeamProvisioningService { statuses: snapshotToMemberSpawnStatuses(filteredRecoveredMixedSnapshot), }; } - const filteredBootstrapSnapshot = bootstrapSnapshot - ? this.filterRemovedMembersFromLaunchSnapshot(bootstrapSnapshot, metaMembers) - : null; const filteredPersisted = filteredRecoveredMixedSnapshot ?? (persisted ? this.filterRemovedMembersFromLaunchSnapshot(persisted, metaMembers) : null); @@ -16891,6 +18286,7 @@ export class TeamProvisioningService { if ( this.hasPrimaryOnlyLaneAwareLaunchMetadata(filteredPersisted) && !this.hasLeadInboxLaunchReconcileHeartbeat(filteredPersisted, leadInboxMessages) && + !this.needsBootstrapAcceptanceReconcile(filteredPersisted, filteredBootstrapSnapshot) && !(await this.hasBootstrapTranscriptLaunchReconcileOutcome(filteredPersisted)) ) { return { @@ -17233,48 +18629,113 @@ export class TeamProvisioningService { } catch { return []; } - const projectPath = config?.projectPath?.trim(); - if (!projectPath) { - return []; - } - - const projectDir = path.join(getProjectsBasePath(), extractBaseDir(encodePath(projectPath))); - let entries: fs.Dirent[]; - try { - entries = await fs.promises.readdir(projectDir, { withFileTypes: true }); - } catch { - return []; - } - const outcomes: BootstrapTranscriptOutcome[] = []; - const jsonlFiles = entries - .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) - .sort((left, right) => right.name.localeCompare(left.name)); + const projectDirs = await this.collectBootstrapTranscriptProjectDirs( + teamName, + memberName, + config + ); const contextMemberNames = [ memberName, ...((config?.members ?? []) .map((member) => member.name?.trim()) .filter((name): name is string => Boolean(name)) ?? []), ]; - for (const entry of jsonlFiles) { - if (config?.leadSessionId && entry.name === `${config.leadSessionId}.jsonl`) { + for (const projectDir of projectDirs) { + let entries: fs.Dirent[]; + try { + entries = await fs.promises.readdir(projectDir, { withFileTypes: true }); + } catch { continue; } - const outcome = await this.readRecentBootstrapTranscriptOutcome( - path.join(projectDir, entry.name), - sinceMs, - memberName, - teamName, - { contextMemberNames } - ); - if (outcome) { - outcomes.push(outcome); + + const jsonlFiles = entries + .filter((entry) => entry.isFile() && entry.name.endsWith('.jsonl')) + .sort((left, right) => right.name.localeCompare(left.name)); + for (const entry of jsonlFiles) { + if (config?.leadSessionId && entry.name === `${config.leadSessionId}.jsonl`) { + continue; + } + const outcome = await this.readRecentBootstrapTranscriptOutcome( + path.join(projectDir, entry.name), + sinceMs, + memberName, + teamName, + { contextMemberNames } + ); + if (outcome) { + outcomes.push(outcome); + } } } return outcomes; } + private async collectBootstrapTranscriptProjectDirs( + teamName: string, + memberName: string, + config: Awaited> + ): Promise { + const pathCandidates: string[] = []; + const pathSeen = new Set(); + const pushPath = (value: unknown): void => { + if (typeof value !== 'string') { + return; + } + let trimmed = value.trim(); + while (trimmed.endsWith('/') || trimmed.endsWith('\\')) { + trimmed = trimmed.slice(0, -1); + } + if (!trimmed || pathSeen.has(trimmed)) { + return; + } + pathSeen.add(trimmed); + pathCandidates.push(trimmed); + }; + + pushPath(config?.projectPath); + if (Array.isArray(config?.projectPathHistory)) { + for (let index = config.projectPathHistory.length - 1; index >= 0; index -= 1) { + pushPath(config.projectPathHistory[index]); + } + } + + const normalizedMemberName = memberName.trim().toLowerCase(); + const pushMatchingMemberCwd = (member: { name?: unknown; cwd?: unknown }): void => { + const candidateName = typeof member.name === 'string' ? member.name.trim().toLowerCase() : ''; + if (candidateName && matchesTeamMemberIdentity(candidateName, normalizedMemberName)) { + pushPath(member.cwd); + } + }; + for (const member of config?.members ?? []) { + pushMatchingMemberCwd(member); + } + + const metaMembers = await this.membersMetaStore.getMembers(teamName).catch(() => []); + for (const member of metaMembers) { + pushMatchingMemberCwd(member); + } + + const dirs: string[] = []; + const dirSeen = new Set(); + const pushDir = (dir: string): void => { + if (!dir || dirSeen.has(dir)) { + return; + } + dirSeen.add(dir); + dirs.push(dir); + }; + for (const projectPath of pathCandidates) { + const projectId = extractBaseDir(encodePath(projectPath)); + pushDir(path.join(getProjectsBasePath(), projectId)); + if (projectId.includes('_')) { + pushDir(path.join(getProjectsBasePath(), projectId.replace(/_/g, '-'))); + } + } + return dirs; + } + private selectLatestBootstrapTranscriptOutcome( outcomes: readonly BootstrapTranscriptOutcome[] ): BootstrapTranscriptOutcome | null { @@ -18070,7 +19531,7 @@ export class TeamProvisioningService { ? this.runs.get(this.getTrackedRunId(teamName)!)?.child?.pid : undefined; const pids = new Set(); - const rows: Array<{ pid: number; command: string }> = []; + const rows: { pid: number; command: string }[] = []; if (process.platform === 'win32') { try { @@ -18827,14 +20288,18 @@ export class TeamProvisioningService { run.provisioningOutputParts.push(warningText); } run.lastRetryAt = Date.now(); + appendProvisioningTrace( + run, + run.progress.state, + retryText, + errorMessage ? `error=${errorMessage}` : undefined + ); run.progress = { ...run.progress, updatedAt: nowIso(), message: retryText, messageSeverity: 'error' as const, - assistantOutput: - buildProgressAssistantOutput(run.provisioningOutputParts) ?? - run.progress.assistantOutput, + assistantOutput: buildProvisioningLiveOutput(run) ?? run.progress.assistantOutput, }; run.onProgress(run.progress); } @@ -20026,7 +21491,7 @@ export class TeamProvisioningService { providerId: run.request.providerId, model: run.request.model, effort: run.request.effort, - members: run.effectiveMembers, + members: run.allEffectiveMembers, } ); await this.cleanupPrelaunchBackup(run.teamName); @@ -20228,7 +21693,7 @@ export class TeamProvisioningService { providerId: run.request.providerId, model: run.request.model, effort: run.request.effort, - members: run.effectiveMembers, + members: run.allEffectiveMembers, } ); @@ -21169,7 +22634,7 @@ export class TeamProvisioningService { providerId: run.request.providerId, model: run.request.model, effort: run.request.effort, - members: run.effectiveMembers, + members: run.allEffectiveMembers, } ); await this.refreshMemberSpawnStatusesFromLeadInbox(run); @@ -21343,6 +22808,37 @@ export class TeamProvisioningService { }; } + private async buildCrossProviderMemberArgs( + primaryProviderId: TeamProviderId, + memberSpecs: TeamCreateRequest['members'] + ): Promise { + const crossProviderIds = new Set(); + for (const member of memberSpecs) { + const memberId = resolveTeamProviderId( + normalizeTeamMemberProviderId(member.providerId) ?? primaryProviderId + ); + if (memberId !== primaryProviderId) { + crossProviderIds.add(memberId); + } + } + const args: string[] = []; + for (const providerId of crossProviderIds) { + try { + const env = await this.buildProvisioningEnv(providerId); + if (env.providerArgs) { + args.push(...env.providerArgs); + } + } catch (error) { + console.error( + `[TeamProvisioningService] Failed to build cross-provider args for provider "${providerId}"`, + error + ); + // Best-effort: don't block launch if cross-provider env resolution fails + } + } + return args; + } + private async resolveControlApiBaseUrl(): Promise { if (!this.controlApiBaseUrlResolver) { return null; @@ -21396,6 +22892,7 @@ export class TeamProvisioningService { } private applyEffectiveLaunchStateToConfig( + teamName: string, config: Record, launchState?: { providerId?: TeamProviderId; @@ -21419,7 +22916,7 @@ export class TeamProvisioningService { (launchState.members ?? []).map((member) => [member.name.toLowerCase(), member] as const) ); - config.members = (config.members as Record[]).map((member) => { + const nextMembers = (config.members as Record[]).map((member) => { if (!member || typeof member !== 'object') { return member; } @@ -21477,6 +22974,53 @@ export class TeamProvisioningService { }); return nextMember; }); + + const existingNames = new Set( + nextMembers + .map((member) => (typeof member.name === 'string' ? member.name.trim().toLowerCase() : '')) + .filter(Boolean) + ); + + for (const member of launchState.members ?? []) { + const name = member.name?.trim(); + if (!name || existingNames.has(name.toLowerCase())) { + continue; + } + + const providerId = normalizeTeamMemberProviderId(member.providerId); + if (providerId !== 'opencode') { + continue; + } + + nextMembers.push(this.buildOpenCodeConfigMemberFromLaunchMember(teamName, member)); + existingNames.add(name.toLowerCase()); + } + + config.members = nextMembers; + } + + private buildOpenCodeConfigMemberFromLaunchMember( + teamName: string, + member: TeamCreateRequest['members'][number] + ): Record { + const name = member.name.trim(); + const configMember: Record = { + name, + agentId: `${name}@${teamName}`, + agentType: 'general-purpose', + role: member.role?.trim() || undefined, + workflow: member.workflow?.trim() || undefined, + isolation: member.isolation === 'worktree' ? 'worktree' : undefined, + providerId: 'opencode', + model: member.model?.trim() || undefined, + effort: isTeamEffortLevel(member.effort) ? member.effort : undefined, + cwd: member.cwd?.trim() || undefined, + joinedAt: Date.now(), + }; + + return Object.fromEntries( + Object.entries(configMember).filter(([, value]) => value !== undefined) + ); } /** @@ -21571,7 +23115,7 @@ export class TeamProvisioningService { : pathHistory; } - this.applyEffectiveLaunchStateToConfig(config, launchState); + this.applyEffectiveLaunchStateToConfig(teamName, config, launchState); await atomicWriteAsync(configPath, JSON.stringify(config, null, 2)); } catch (error) { diff --git a/src/main/services/team/TeamRuntimeLivenessResolver.ts b/src/main/services/team/TeamRuntimeLivenessResolver.ts index 804f9e75..2ce967c6 100644 --- a/src/main/services/team/TeamRuntimeLivenessResolver.ts +++ b/src/main/services/team/TeamRuntimeLivenessResolver.ts @@ -185,6 +185,8 @@ export function resolveTeamMemberRuntimeLiveness( ): ResolvedTeamMemberRuntimeLiveness { const tracked = input.trackedSpawnStatus; const runtimeSessionId = input.runtimeSessionId ?? input.persistedRuntimeSessionId; + const hasConfirmedBootstrap = + tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive'; const diagnostics: string[] = []; if (!input.processTableAvailable) { diagnostics.push('process table unavailable'); @@ -230,15 +232,38 @@ export function resolveTeamMemberRuntimeLiveness( if (runtimePidRow && input.providerId === 'opencode') { const processCommand = sanitizeProcessCommandForDiagnostics(runtimePidRow.command); if (isOpenCodeRuntimeProcess(runtimePidRow.command)) { + if (hasConfirmedBootstrap) { + return result({ + alive: true, + livenessKind: 'runtime_process', + pidSource: 'opencode_bridge', + pid: runtimePidRow.pid, + runtimeSessionId, + processCommand, + runtimeLastSeenAt: tracked?.lastHeartbeatAt ?? tracked?.updatedAt, + runtimeDiagnostic: 'OpenCode runtime process detected after bootstrap confirmation', + diagnostics: [ + ...diagnostics, + 'matched OpenCode runtime pid and process identity', + 'bootstrap confirmed', + ], + }); + } return result({ - alive: true, - livenessKind: 'runtime_process', + alive: false, + livenessKind: 'runtime_process_candidate', pidSource: 'opencode_bridge', pid: runtimePidRow.pid, runtimeSessionId, processCommand, - runtimeDiagnostic: 'OpenCode runtime process detected', - diagnostics: [...diagnostics, 'matched OpenCode runtime pid and process identity'], + runtimeDiagnostic: + 'OpenCode runtime process detected, but teammate bootstrap is not confirmed', + runtimeDiagnosticSeverity: 'warning', + diagnostics: [ + ...diagnostics, + 'matched OpenCode runtime pid and process identity', + 'waiting for teammate bootstrap confirmation', + ], }); } return result({ @@ -257,7 +282,7 @@ export function resolveTeamMemberRuntimeLiveness( }); } - if (tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive') { + if (hasConfirmedBootstrap) { return result({ alive: true, livenessKind: 'confirmed_bootstrap', diff --git a/src/main/services/team/fileLock.ts b/src/main/services/team/fileLock.ts index 9f95fdb1..31bcf28b 100644 --- a/src/main/services/team/fileLock.ts +++ b/src/main/services/team/fileLock.ts @@ -5,18 +5,63 @@ const STALE_TIMEOUT_MS = 30_000; const ACQUIRE_TIMEOUT_MS = 5_000; const RETRY_INTERVAL_MS = 20; -function readLockAge(lockPath: string): number | null { +export interface FileLockOptions { + acquireTimeoutMs?: number; + staleTimeoutMs?: number; + retryIntervalMs?: number; +} + +interface LockInfo { + pid: number | null; + ageMs: number | null; +} + +function readLockInfo(lockPath: string): LockInfo { + let pid: number | null = null; + let ageMs: number | null = null; try { const content = fs.readFileSync(lockPath, 'utf8'); - const ts = parseInt(content.split('\n')[1] ?? '', 10); - if (Number.isFinite(ts)) return Date.now() - ts; + const lines = content.split('\n'); + const parsedPid = parseInt(lines[0] ?? '', 10); + if (Number.isFinite(parsedPid) && parsedPid > 0) { + pid = parsedPid; + } + const ts = parseInt(lines[1] ?? '', 10); + if (Number.isFinite(ts)) { + ageMs = Date.now() - ts; + } } catch { /* lock may have been released concurrently */ } - return null; + if (ageMs === null) { + try { + ageMs = Date.now() - fs.statSync(lockPath).mtimeMs; + } catch { + /* lock may have been released concurrently */ + } + } + return { pid, ageMs }; } -function tryAcquire(lockPath: string): boolean { +function isProcessAlive(pid: number): boolean { + try { + process.kill(pid, 0); + return true; + } catch (error) { + const code = (error as NodeJS.ErrnoException).code; + return code !== 'ESRCH'; + } +} + +function shouldBreakExistingLock(lockPath: string, staleTimeoutMs: number): boolean { + const info = readLockInfo(lockPath); + if (info.pid !== null && !isProcessAlive(info.pid)) { + return true; + } + return info.ageMs !== null && info.ageMs > staleTimeoutMs; +} + +function tryAcquire(lockPath: string, options: Required): boolean { try { const dir = path.dirname(lockPath); if (!fs.existsSync(dir)) { @@ -28,8 +73,7 @@ function tryAcquire(lockPath: string): boolean { return true; } catch (err) { if ((err as NodeJS.ErrnoException).code === 'EEXIST') { - const age = readLockAge(lockPath); - if (age !== null && age > STALE_TIMEOUT_MS) { + if (shouldBreakExistingLock(lockPath, options.staleTimeoutMs)) { try { fs.unlinkSync(lockPath); } catch { @@ -50,15 +94,24 @@ function releaseLock(lockPath: string): void { } } -export async function withFileLock(filePath: string, fn: () => Promise): Promise { +export async function withFileLock( + filePath: string, + fn: () => Promise, + options: FileLockOptions = {} +): Promise { + const resolvedOptions = { + acquireTimeoutMs: options.acquireTimeoutMs ?? ACQUIRE_TIMEOUT_MS, + staleTimeoutMs: options.staleTimeoutMs ?? STALE_TIMEOUT_MS, + retryIntervalMs: options.retryIntervalMs ?? RETRY_INTERVAL_MS, + }; const lockPath = `${filePath}.lock`; - const deadline = Date.now() + ACQUIRE_TIMEOUT_MS; + const deadline = Date.now() + resolvedOptions.acquireTimeoutMs; - while (!tryAcquire(lockPath)) { + while (!tryAcquire(lockPath, resolvedOptions)) { if (Date.now() >= deadline) { throw new Error(`File lock timeout: ${filePath}`); } - await new Promise((resolve) => setTimeout(resolve, RETRY_INTERVAL_MS)); + await new Promise((resolve) => setTimeout(resolve, resolvedOptions.retryIntervalMs)); } try { diff --git a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts index 67db3b43..eca989d8 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeBridgeCommandContract.ts @@ -131,7 +131,7 @@ export interface OpenCodeCleanupHostsCommandBody { export interface OpenCodeCleanupHostsCommandData { cleaned: number; remaining: number; - hosts: Array<{ + hosts: { hostKey: string; projectPath: string; pid: number; @@ -146,7 +146,7 @@ export interface OpenCodeCleanupHostsCommandData { | 'failed'; reason: string; leaseCount: number; - }>; + }[]; diagnostics: string[]; } @@ -240,10 +240,17 @@ export interface OpenCodeBackfillTaskLedgerCommandBody { workspaceRoot?: string; deliveryContextPath?: string; attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode; + evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode; dryRun?: boolean; } export type OpenCodeBackfillTaskLedgerAttributionMode = 'strict-delivery' | 'compatible'; +export type OpenCodeBackfillTaskLedgerEvidenceMode = + | 'off' + | 'metadata-only' + | 'chain-only' + | 'snapshot-probe' + | 'snapshot-auto'; export type OpenCodeBackfillTaskLedgerOutcome = | 'imported' @@ -264,13 +271,19 @@ export interface OpenCodeBackfillTaskLedgerCommandData { workspaceRoot?: string; dryRun: boolean; attributionMode?: OpenCodeBackfillTaskLedgerAttributionMode; + evidenceMode?: OpenCodeBackfillTaskLedgerEvidenceMode; + strictWindowCandidateCount?: number; + openCodeDbFingerprint?: string; + deliveryLedgerFingerprint?: string; + snapshotShapeFingerprint?: string; + retryAfterReason?: string; scannedSessions: number; scannedToolparts: number; candidateEvents: number; importedEvents: number; skippedEvents: number; outcome: OpenCodeBackfillTaskLedgerOutcome; - notices: Array<{ severity: 'warning'; message: string; code: string }>; + notices: { severity: 'warning'; message: string; code: string }[]; diagnostics: string[]; } diff --git a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts index 5704dd95..cba24f84 100644 --- a/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts +++ b/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts @@ -4,13 +4,13 @@ import type { OpenCodeTeamLaunchReadinessState, } from '../readiness/OpenCodeTeamLaunchReadiness'; import type { + OpenCodeBackfillTaskLedgerCommandBody, + OpenCodeBackfillTaskLedgerCommandData, OpenCodeBridgeCommandName, OpenCodeBridgeDiagnosticEvent, OpenCodeBridgeFailureKind, OpenCodeBridgeResult, OpenCodeBridgeRuntimeSnapshot, - OpenCodeBackfillTaskLedgerCommandBody, - OpenCodeBackfillTaskLedgerCommandData, OpenCodeCleanupHostsCommandBody, OpenCodeCleanupHostsCommandData, OpenCodeLaunchTeamCommandBody, @@ -308,6 +308,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort { ...(input.workspaceRoot ? { workspaceRoot: input.workspaceRoot } : {}), dryRun: input.dryRun === true, ...(input.attributionMode ? { attributionMode: input.attributionMode } : {}), + ...(input.evidenceMode ? { evidenceMode: input.evidenceMode } : {}), scannedSessions: 0, scannedToolparts: 0, candidateEvents: 0, diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts index 0cb284d0..b87140d0 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryLedger.ts @@ -1,12 +1,12 @@ import { stableHash } from '../bridge/OpenCodeBridgeCommandContract'; import { VersionedJsonStore, VersionedJsonStoreError } from '../store/VersionedJsonStore'; -import type { AgentActionMode, TaskRef } from '@shared/types/team'; import type { OpenCodeDeliveryResponseObservation, OpenCodeDeliveryResponseState, OpenCodeDeliveryVisibleReplyCorrelation, } from '../bridge/OpenCodeBridgeCommandContract'; +import type { AgentActionMode, TaskRef } from '@shared/types/team'; export const OPENCODE_PROMPT_DELIVERY_LEDGER_SCHEMA_VERSION = 1; export const OPENCODE_PROMPT_DELIVERY_RESPONDED_RETENTION_MS = 7 * 24 * 60 * 60 * 1000; @@ -603,7 +603,7 @@ export function hashOpenCodePromptDeliveryPayload(input: { replyRecipient: string; actionMode?: AgentActionMode | null; taskRefs?: TaskRef[]; - attachments?: Array<{ id?: string; filename?: string; mimeType?: string; size?: number }>; + attachments?: { id?: string; filename?: string; mimeType?: string; size?: number }[]; source?: string; }): string { return `sha256:${stableHash({ diff --git a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts index 1f9f7073..7247620b 100644 --- a/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts +++ b/src/main/services/team/opencode/delivery/OpenCodePromptDeliveryWatchdog.ts @@ -1,6 +1,6 @@ -import type { AgentActionMode, InboxMessage, TaskRef } from '@shared/types/team'; import type { OpenCodeDeliveryResponseState } from '../bridge/OpenCodeBridgeCommandContract'; import type { OpenCodePromptDeliveryStatus } from './OpenCodePromptDeliveryLedger'; +import type { AgentActionMode, InboxMessage, TaskRef } from '@shared/types/team'; export const OPENCODE_PROMPT_DELIVERY_OBSERVE_DELAY_MS = 3_000; export const OPENCODE_PROMPT_DELIVERY_RETRY_DELAY_MS = 15_000; diff --git a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts index ea3b8c2d..8939a180 100644 --- a/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts +++ b/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts @@ -8,6 +8,8 @@ import { withFileLock } from '../../fileLock'; import { createDefaultRuntimeStoreManifest, + createRuntimeStoreManifestStore, + OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION, validateRuntimeStoreManifest, } from './RuntimeStoreManifest'; @@ -26,6 +28,11 @@ const OPENCODE_TEAM_RUNTIME_LANES_DIR = 'lanes'; const OPENCODE_TEAM_RUNTIME_LANES_INDEX_FILE = 'lanes.json'; const OPENCODE_RUNTIME_MANIFEST_FILE = 'manifest.json'; const OPENCODE_RUNTIME_RUN_TOMBSTONES_FILE = 'opencode-run-tombstones.json'; +const OPENCODE_LANE_INDEX_LOCK_OPTIONS = { + acquireTimeoutMs: 30_000, + staleTimeoutMs: 25_000, + retryIntervalMs: 25, +} as const; export interface OpenCodeRuntimeLaneIndexEntry { laneId: string; @@ -360,9 +367,13 @@ export async function writeOpenCodeRuntimeLaneIndex( index: OpenCodeRuntimeLaneIndex ): Promise { const filePath = getOpenCodeRuntimeLaneIndexPath(teamsBasePath, teamName); - await withFileLock(filePath, async () => { - await writeOpenCodeRuntimeLaneIndexUnlocked(teamsBasePath, teamName, index); - }); + await withFileLock( + filePath, + async () => { + await writeOpenCodeRuntimeLaneIndexUnlocked(teamsBasePath, teamName, index); + }, + OPENCODE_LANE_INDEX_LOCK_OPTIONS + ); } export async function upsertOpenCodeRuntimeLaneIndexEntry(params: { @@ -373,17 +384,93 @@ export async function upsertOpenCodeRuntimeLaneIndexEntry(params: { diagnostics?: string[]; }): Promise { const filePath = getOpenCodeRuntimeLaneIndexPath(params.teamsBasePath, params.teamName); - await withFileLock(filePath, async () => { - const index = await readOpenCodeRuntimeLaneIndexUnlocked(params.teamsBasePath, params.teamName); - index.updatedAt = new Date().toISOString(); - index.lanes[params.laneId] = { - laneId: params.laneId, - state: params.state, - updatedAt: index.updatedAt, - diagnostics: params.diagnostics?.length ? [...params.diagnostics] : undefined, - }; - await writeOpenCodeRuntimeLaneIndexUnlocked(params.teamsBasePath, params.teamName, index); + await withFileLock( + filePath, + async () => { + const index = await readOpenCodeRuntimeLaneIndexUnlocked( + params.teamsBasePath, + params.teamName + ); + index.updatedAt = new Date().toISOString(); + index.lanes[params.laneId] = { + laneId: params.laneId, + state: params.state, + updatedAt: index.updatedAt, + diagnostics: params.diagnostics?.length ? [...params.diagnostics] : undefined, + }; + await writeOpenCodeRuntimeLaneIndexUnlocked(params.teamsBasePath, params.teamName, index); + }, + OPENCODE_LANE_INDEX_LOCK_OPTIONS + ); +} + +export async function setOpenCodeRuntimeActiveRunManifest(params: { + teamsBasePath: string; + teamName: string; + laneId?: string | null; + runId: string | null; + clock?: () => Date; +}): Promise { + const manifestPath = getOpenCodeRuntimeManifestPath( + params.teamsBasePath, + params.teamName, + params.laneId + ); + await ensureRuntimeManifestEnvelope( + manifestPath, + params.teamName, + params.clock ?? (() => new Date()) + ); + const manifestStore = createRuntimeStoreManifestStore({ + filePath: manifestPath, + teamName: params.teamName, + clock: params.clock, }); + await manifestStore.setActiveRun({ runId: params.runId }); +} + +async function ensureRuntimeManifestEnvelope( + manifestPath: string, + teamName: string, + clock: () => Date +): Promise { + let raw: string; + try { + raw = await readFile(manifestPath, 'utf8'); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === 'ENOENT') { + return; + } + throw error; + } + + const parsed = JSON.parse(raw) as unknown; + if ( + parsed && + typeof parsed === 'object' && + !Array.isArray(parsed) && + Object.prototype.hasOwnProperty.call(parsed, 'data') + ) { + return; + } + + const manifest = validateRuntimeStoreManifest(parsed); + await mkdir(path.dirname(manifestPath), { recursive: true }); + await atomicWriteAsync( + manifestPath, + `${JSON.stringify( + { + schemaVersion: OPENCODE_RUNTIME_STORE_MANIFEST_SCHEMA_VERSION, + updatedAt: clock().toISOString(), + data: { + ...manifest, + teamName, + }, + }, + null, + 2 + )}\n` + ); } export async function removeOpenCodeRuntimeLaneIndexEntry(params: { @@ -392,15 +479,22 @@ export async function removeOpenCodeRuntimeLaneIndexEntry(params: { laneId: string; }): Promise { const filePath = getOpenCodeRuntimeLaneIndexPath(params.teamsBasePath, params.teamName); - await withFileLock(filePath, async () => { - const index = await readOpenCodeRuntimeLaneIndexUnlocked(params.teamsBasePath, params.teamName); - if (!index.lanes[params.laneId]) { - return; - } - delete index.lanes[params.laneId]; - index.updatedAt = new Date().toISOString(); - await writeOpenCodeRuntimeLaneIndexUnlocked(params.teamsBasePath, params.teamName, index); - }); + await withFileLock( + filePath, + async () => { + const index = await readOpenCodeRuntimeLaneIndexUnlocked( + params.teamsBasePath, + params.teamName + ); + if (!index.lanes[params.laneId]) { + return; + } + delete index.lanes[params.laneId]; + index.updatedAt = new Date().toISOString(); + await writeOpenCodeRuntimeLaneIndexUnlocked(params.teamsBasePath, params.teamName, index); + }, + OPENCODE_LANE_INDEX_LOCK_OPTIONS + ); } export async function clearOpenCodeRuntimeLaneStorage(params: { diff --git a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts index 17f562f7..99e6174e 100644 --- a/src/main/services/team/opencode/store/RuntimeStoreManifest.ts +++ b/src/main/services/team/opencode/store/RuntimeStoreManifest.ts @@ -274,6 +274,57 @@ export class RuntimeStoreManifestStore { return readStoreDataOrThrow(this.store); } + async setActiveRun(input: { + runId: string | null; + capabilitySnapshotId?: string | null; + behaviorFingerprint?: string | null; + }): Promise { + const normalizedRunId = input.runId?.trim() || null; + const result = await this.store.updateLocked((manifest) => { + const normalizedCapabilitySnapshotId = + input.capabilitySnapshotId === undefined + ? manifest.activeCapabilitySnapshotId + : input.capabilitySnapshotId?.trim() || null; + const normalizedBehaviorFingerprint = + input.behaviorFingerprint === undefined + ? manifest.activeBehaviorFingerprint + : input.behaviorFingerprint?.trim() || null; + const changed = + manifest.activeRunId !== normalizedRunId || + manifest.activeCapabilitySnapshotId !== normalizedCapabilitySnapshotId || + manifest.activeBehaviorFingerprint !== normalizedBehaviorFingerprint || + this.isActiveRunOnlyWatermark(manifest); + if (!changed) { + return manifest; + } + + return { + ...manifest, + activeRunId: normalizedRunId, + activeCapabilitySnapshotId: normalizedCapabilitySnapshotId, + activeBehaviorFingerprint: normalizedBehaviorFingerprint, + highWatermark: this.resolveActiveRunWatermark(manifest), + updatedAt: this.clock().toISOString(), + }; + }); + return result.data; + } + + private isActiveRunOnlyWatermark(manifest: RuntimeStoreManifest): boolean { + return ( + manifest.highWatermark > 0 && + manifest.entries.length === 0 && + manifest.lastCommittedBatchId === null + ); + } + + private resolveActiveRunWatermark(manifest: RuntimeStoreManifest): number { + if (this.isActiveRunOnlyWatermark(manifest)) { + return 0; + } + return manifest.highWatermark; + } + async markBatchPreparing(batch: RuntimeStoreWriteBatch): Promise { await this.store.updateLocked((manifest) => ({ ...manifest, diff --git a/src/main/services/team/progressPayload.ts b/src/main/services/team/progressPayload.ts index e7ecb51c..baf8898c 100644 --- a/src/main/services/team/progressPayload.ts +++ b/src/main/services/team/progressPayload.ts @@ -16,10 +16,16 @@ import type { TeamLaunchDiagnosticItem } from '@shared/types'; export const PROGRESS_LOG_TAIL_LINES = 200; export const PROGRESS_OUTPUT_TAIL_PARTS = 20; +export const PROGRESS_TRACE_TAIL_LINES = 120; export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20; const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500; +const PROGRESS_TRACE_TEXT_LIMIT = 800; +const PROVIDER_API_KEY_FLAG_PATTERN = + /(--(?:openai|codex|anthropic)[-_]api[-_]key(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; const SECRET_FLAG_PATTERN = - /(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; + /(--(?:api[-_]key|token|password|secret|authorization|auth[-_]token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; +const SECRET_ENV_ASSIGNMENT_PATTERN = + /\b([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)[A-Z0-9_]*\s*=\s*)("[^"]*"|'[^']*'|\S+)/gi; /** * Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n" @@ -57,15 +63,65 @@ export function buildProgressAssistantOutput( return joined.trim().length === 0 ? undefined : joined; } -function boundDiagnosticText(value: string | undefined): string | undefined { - const trimmed = value?.replace(/\s+/g, ' ').trim(); - if (!trimmed) { +function boundRedactedText( + value: string | undefined, + limit: number, + whitespace: 'collapse' | 'preserve' +): string | undefined { + const prepared = whitespace === 'collapse' ? value?.replace(/\s+/g, ' ').trim() : value?.trim(); + if (!prepared) { return undefined; } - const redacted = trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]'); - return redacted.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - ? `${redacted.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...` - : redacted; + const redacted = prepared + .replace(PROVIDER_API_KEY_FLAG_PATTERN, '$1[redacted]') + .replace(SECRET_FLAG_PATTERN, '$1[redacted]') + .replace(SECRET_ENV_ASSIGNMENT_PATTERN, '$1[redacted]') + .replace(/```/g, "'''"); + return redacted.length > limit ? `${redacted.slice(0, limit - 3).trimEnd()}...` : redacted; +} + +function boundDiagnosticText(value: string | undefined): string | undefined { + return boundRedactedText(value, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT, 'collapse'); +} + +export function buildProgressTraceLine(input: { + timestamp: string; + state: string; + message: string; + detail?: string; +}): string { + const message = boundRedactedText(input.message, PROGRESS_TRACE_TEXT_LIMIT, 'collapse') ?? ''; + const detail = boundRedactedText(input.detail, PROGRESS_TRACE_TEXT_LIMIT, 'collapse'); + return detail + ? `${input.timestamp} [${input.state}] ${message} - ${detail}` + : `${input.timestamp} [${input.state}] ${message}`; +} + +export function buildProgressTraceTail( + lines: readonly string[], + maxLines: number = PROGRESS_TRACE_TAIL_LINES +): string | undefined { + return buildProgressLogsTail(lines, maxLines); +} + +export function buildProgressLiveOutput( + traceLines: readonly string[], + assistantParts: readonly string[], + options?: { + maxTraceLines?: number; + maxAssistantParts?: number; + } +): string | undefined { + const trace = buildProgressTraceTail(traceLines, options?.maxTraceLines); + const assistant = buildProgressAssistantOutput(assistantParts, options?.maxAssistantParts); + if (!trace) { + return assistant; + } + const traceBlock = `**Launch trace**\n\n\`\`\`text\n${trace}\n\`\`\``; + if (!assistant) { + return traceBlock; + } + return `${traceBlock}\n\n**Runtime output**\n\n${assistant}`; } export function boundLaunchDiagnostics( diff --git a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts index 267b452b..d1d7f1ad 100644 --- a/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/OpenCodeTeamRuntimeAdapter.ts @@ -556,6 +556,14 @@ function mapBridgeMemberToRuntimeEvidence( : runtimeMaterialized || sessionId ? 'OpenCode session exists without verified runtime pid' : undefined; + const runtimeDiagnosticSeverity = failed + ? 'error' + : pendingRuntimeObserved || + launchState === 'permission_blocked' || + runtimeMaterialized || + sessionId + ? 'warning' + : undefined; return { memberName, providerId: 'opencode', @@ -585,6 +593,7 @@ function mapBridgeMemberToRuntimeEvidence( livenessKind, ...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}), ...(runtimeDiagnostic ? { runtimeDiagnostic } : {}), + ...(runtimeDiagnosticSeverity ? { runtimeDiagnosticSeverity } : {}), diagnostics, }; } diff --git a/src/main/services/team/runtime/TeamRuntimeAdapter.ts b/src/main/services/team/runtime/TeamRuntimeAdapter.ts index 1fd06f8e..1ce62ce3 100644 --- a/src/main/services/team/runtime/TeamRuntimeAdapter.ts +++ b/src/main/services/team/runtime/TeamRuntimeAdapter.ts @@ -4,6 +4,7 @@ import type { PersistedTeamLaunchPhase, PersistedTeamLaunchSnapshot, TeamAgentRuntimeBackendType, + TeamAgentRuntimeDiagnosticSeverity, TeamAgentRuntimeLivenessKind, TeamAgentRuntimePidSource, TeamLaunchAggregateState, @@ -78,6 +79,7 @@ export interface TeamRuntimeMemberLaunchEvidence { livenessKind?: TeamAgentRuntimeLivenessKind; pidSource?: TeamAgentRuntimePidSource; runtimeDiagnostic?: string; + runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity; diagnostics: string[]; } diff --git a/src/main/services/team/runtimeTeammateMode.ts b/src/main/services/team/runtimeTeammateMode.ts index 648961a0..da393e4c 100644 --- a/src/main/services/team/runtimeTeammateMode.ts +++ b/src/main/services/team/runtimeTeammateMode.ts @@ -58,22 +58,42 @@ export async function resolveDesktopTeammateModeDecision( }; } - if (explicitMode === 'auto' || explicitMode === 'in-process') { + if (explicitMode === 'auto') { + return { + injectedTeammateMode: null, + forceProcessTeammates: true, + }; + } + + if (explicitMode === 'in-process') { return { injectedTeammateMode: null, forceProcessTeammates: false, }; } - if (!(await isTmuxAvailable())) { - return { - injectedTeammateMode: null, - forceProcessTeammates: false, - }; - } + const tmuxAvailable = await isTmuxAvailable(); return { - injectedTeammateMode: 'tmux', + injectedTeammateMode: tmuxAvailable ? 'tmux' : null, forceProcessTeammates: true, }; } + +export function applyDesktopTeammateModeDecisionToEnv( + env: NodeJS.ProcessEnv, + decision: Pick +): void { + if (decision.forceProcessTeammates) { + env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1'; + return; + } + + delete env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES; +} + +export function buildDesktopTeammateModeCliArgs( + decision: Pick +): string[] { + return decision.injectedTeammateMode ? ['--teammate-mode', decision.injectedTeammateMode] : []; +} diff --git a/src/main/services/team/stallMonitor/ActiveTeamRegistry.ts b/src/main/services/team/stallMonitor/ActiveTeamRegistry.ts index 8e838772..75a54c77 100644 --- a/src/main/services/team/stallMonitor/ActiveTeamRegistry.ts +++ b/src/main/services/team/stallMonitor/ActiveTeamRegistry.ts @@ -16,6 +16,11 @@ interface TeamLogSourceTrackingHandle { ): Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>; } +function unrefBackgroundTimer(timer: ReturnType): void { + const maybeTimer = timer as { unref?: () => void }; + maybeTimer.unref?.(); +} + export class ActiveTeamRegistry { private readonly activeTeams = new Set(); private reconcileTimer: ReturnType | null = null; @@ -61,6 +66,7 @@ export class ActiveTeamRegistry { this.reconcileTimer = setInterval(() => { void this.reconcile(); }, this.reconcileIntervalMs); + unrefBackgroundTimer(this.reconcileTimer); } async stop(): Promise { diff --git a/src/main/services/team/stallMonitor/OpenCodeTaskStallEvidenceSource.ts b/src/main/services/team/stallMonitor/OpenCodeTaskStallEvidenceSource.ts new file mode 100644 index 00000000..f2a29fca --- /dev/null +++ b/src/main/services/team/stallMonitor/OpenCodeTaskStallEvidenceSource.ts @@ -0,0 +1,506 @@ +import { ClaudeMultimodelBridgeService } from '../../runtime/ClaudeMultimodelBridgeService'; +import { canonicalizeAgentTeamsToolName } from '../agentTeamsToolNames'; +import { ClaudeBinaryResolver } from '../ClaudeBinaryResolver'; + +import type { + OpenCodeRuntimeTranscriptLogMessage, + OpenCodeRuntimeTranscriptLogToolCall, +} from '../../runtime/ClaudeMultimodelBridgeService'; +import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; +import type { TeamTaskStallExactRow } from './TeamTaskStallTypes'; +import type { ParsedMessage } from '@main/types'; +import type { TeamProviderId, TeamTask } from '@shared/types'; + +const OPENCODE_STALL_TRANSCRIPT_LIMIT = 500; + +const TASK_STALL_MARKER_TOOL_NAMES = new Set([ + 'task_start', + 'task_add_comment', + 'task_set_status', + 'task_complete', + 'review_start', + 'review_request', + 'review_approve', + 'review_request_changes', +]); + +const TASK_REFERENCE_KEYS = new Set([ + 'taskid', + 'task_id', + 'targetid', + 'targettaskid', + 'target_task_id', + 'canonicalid', + 'canonical_id', + 'displayid', + 'display_id', +]); + +const TEAM_REFERENCE_KEYS = new Set(['team', 'teamid', 'team_id', 'teamname', 'team_name']); + +interface BinaryResolverLike { + resolve(): Promise; +} + +interface RuntimeBridgeLike { + getOpenCodeTranscript( + binaryPath: string, + params: { + teamId: string; + memberName: string; + limit?: number; + } + ): Promise>>; +} + +export interface OpenCodeTaskStallEvidence { + recordsByTaskId: Map; + exactRowsByFilePath: Map; +} + +function emptyEvidence(): OpenCodeTaskStallEvidence { + return { + recordsByTaskId: new Map(), + exactRowsByFilePath: new Map(), + }; +} + +function normalizeMemberNameKey(name: string | undefined): string | null { + const normalized = name?.trim().toLowerCase(); + return normalized ? normalized : null; +} + +function normalizeTaskRef(value: unknown): string | null { + if (typeof value !== 'string' && typeof value !== 'number') { + return null; + } + + const normalized = String(value).trim().replace(/^#/, '').toLowerCase(); + return normalized.length > 0 ? normalized : null; +} + +function buildTaskRefSet(task: TeamTask): Set { + return new Set( + [task.id, task.displayId] + .map(normalizeTaskRef) + .filter((value): value is string => value !== null) + ); +} + +function collectNormalizedRefs(value: unknown, depth = 0): Set { + const refs = new Set(); + if (depth > 4 || value === null || value === undefined) { + return refs; + } + + const normalized = normalizeTaskRef(value); + if (normalized) { + refs.add(normalized); + } + + if (Array.isArray(value)) { + for (const item of value) { + for (const ref of collectNormalizedRefs(item, depth + 1)) { + refs.add(ref); + } + } + return refs; + } + + if (typeof value === 'object') { + for (const nestedValue of Object.values(value as Record)) { + for (const ref of collectNormalizedRefs(nestedValue, depth + 1)) { + refs.add(ref); + } + } + } + + return refs; +} + +function collectExplicitRefsForKeys(value: unknown, keys: Set, depth = 0): Set { + const refs = new Set(); + if (depth > 4 || value === null || value === undefined) { + return refs; + } + + if (Array.isArray(value)) { + for (const item of value) { + for (const ref of collectExplicitRefsForKeys(item, keys, depth + 1)) { + refs.add(ref); + } + } + return refs; + } + + if (typeof value !== 'object') { + return refs; + } + + for (const [key, nestedValue] of Object.entries(value as Record)) { + if (keys.has(key.toLowerCase())) { + for (const ref of collectNormalizedRefs(nestedValue)) { + refs.add(ref); + } + continue; + } + + for (const ref of collectExplicitRefsForKeys(nestedValue, keys, depth + 1)) { + refs.add(ref); + } + } + + return refs; +} + +function refsIntersect(left: Set, right: Set): boolean { + for (const value of left) { + if (right.has(value)) { + return true; + } + } + return false; +} + +function valueReferencesTask(value: unknown, taskRefs: Set, depth = 0): boolean { + if (depth > 4 || value === null || value === undefined || taskRefs.size === 0) { + return false; + } + + const normalized = normalizeTaskRef(value); + if (normalized && taskRefs.has(normalized)) { + return true; + } + + if (Array.isArray(value)) { + return value.some((item) => valueReferencesTask(item, taskRefs, depth + 1)); + } + + if (typeof value === 'object') { + return Object.entries(value as Record).some(([key, nestedValue]) => { + const normalizedKey = key.toLowerCase(); + if (TASK_REFERENCE_KEYS.has(normalizedKey)) { + return valueReferencesTask(nestedValue, taskRefs, depth + 1); + } + return depth < 2 && valueReferencesTask(nestedValue, taskRefs, depth + 1); + }); + } + + return false; +} + +function markerInputReferencesTaskInTeam( + input: unknown, + teamName: string, + taskRefs: Set +): boolean { + const normalizedTeamName = normalizeTaskRef(teamName); + const explicitTeamRefs = collectExplicitRefsForKeys(input, TEAM_REFERENCE_KEYS); + if ( + normalizedTeamName && + explicitTeamRefs.size > 0 && + !explicitTeamRefs.has(normalizedTeamName) + ) { + return false; + } + + const explicitTaskRefs = collectExplicitRefsForKeys(input, TASK_REFERENCE_KEYS); + if (explicitTaskRefs.size > 0) { + return refsIntersect(explicitTaskRefs, taskRefs); + } + + return valueReferencesTask(input, taskRefs); +} + +function buildSyntheticFilePath(teamName: string, owner: string): string { + return `opencode-runtime:${teamName}:${normalizeMemberNameKey(owner) ?? owner}`; +} + +function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMessage | null { + const timestamp = new Date(message.timestamp); + if (Number.isNaN(timestamp.getTime())) { + return null; + } + + return { + uuid: message.uuid, + parentUuid: message.parentUuid, + type: message.type, + timestamp, + role: message.role, + content: typeof message.content === 'string' ? message.content : [], + model: message.model, + agentName: message.agentName, + isSidechain: true, + isMeta: message.isMeta, + sessionId: message.sessionId, + toolCalls: message.toolCalls.map((toolCall) => ({ + id: toolCall.id, + name: toolCall.name, + input: toolCall.input, + isTask: toolCall.isTask, + ...(toolCall.taskDescription ? { taskDescription: toolCall.taskDescription } : {}), + ...(toolCall.taskSubagentType ? { taskSubagentType: toolCall.taskSubagentType } : {}), + })), + toolResults: message.toolResults.map((toolResult) => ({ + toolUseId: toolResult.toolUseId, + content: toolResult.content, + isError: toolResult.isError, + })), + ...(message.sourceToolUseID ? { sourceToolUseID: message.sourceToolUseID } : {}), + ...(message.sourceToolAssistantUUID + ? { sourceToolAssistantUUID: message.sourceToolAssistantUUID } + : {}), + ...(message.subtype ? { subtype: message.subtype } : {}), + ...(message.level ? { level: message.level } : {}), + }; +} + +function toExactRow( + message: OpenCodeRuntimeTranscriptLogMessage, + filePath: string, + sourceOrder: number +): TeamTaskStallExactRow | null { + const parsedMessage = toParsedMessage(message); + if (!parsedMessage) { + return null; + } + + return { + filePath, + sourceOrder, + messageUuid: parsedMessage.uuid, + timestamp: parsedMessage.timestamp.toISOString(), + parsedMessage, + ...(message.sourceToolUseID ? { sourceToolUseId: message.sourceToolUseID } : {}), + ...(message.sourceToolAssistantUUID + ? { sourceToolAssistantUuid: message.sourceToolAssistantUUID } + : {}), + ...(message.subtype === 'turn_duration' || message.subtype === 'init' + ? { systemSubtype: message.subtype } + : {}), + toolUseIds: parsedMessage.toolCalls.map((toolCall) => toolCall.id), + toolResultIds: parsedMessage.toolResults.map((toolResult) => toolResult.toolUseId), + }; +} + +function buildTaskRef(task: TeamTask, teamName: string): BoardTaskActivityRecord['task'] { + return { + locator: { + ref: task.id, + refKind: 'canonical', + canonicalId: task.id, + }, + resolution: 'resolved', + taskRef: { + taskId: task.id, + displayId: task.displayId ?? task.id.slice(0, 8), + teamName, + }, + }; +} + +function buildActionCategory( + toolName: string +): NonNullable['category'] { + switch (toolName) { + case 'task_add_comment': + return 'comment'; + case 'review_start': + case 'review_request': + case 'review_approve': + case 'review_request_changes': + return 'review'; + case 'task_set_owner': + return 'assignment'; + default: + return 'status'; + } +} + +function extractCommentId(input: Record): string | undefined { + const commentId = input.commentId ?? input.comment_id; + return typeof commentId === 'string' && commentId.trim().length > 0 + ? commentId.trim() + : undefined; +} + +function buildRecord(args: { + teamName: string; + task: TeamTask; + owner: string; + sessionId: string; + message: OpenCodeRuntimeTranscriptLogMessage; + toolCall: OpenCodeRuntimeTranscriptLogToolCall; + sourceOrder: number; + filePath: string; + canonicalToolName: string; +}): BoardTaskActivityRecord { + const taskRef = buildTaskRef(args.task, args.teamName); + const commentId = extractCommentId(args.toolCall.input); + return { + id: `opencode-stall:${args.teamName}:${args.task.id}:${args.message.uuid}:${args.toolCall.id}`, + timestamp: new Date(args.message.timestamp).toISOString(), + task: taskRef, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: args.owner, + role: 'member', + sessionId: args.sessionId, + isSidechain: true, + }, + actorContext: { + relation: 'same_task', + activeTask: taskRef, + activePhase: args.task.reviewState === 'review' ? 'review' : 'work', + }, + action: { + canonicalToolName: args.canonicalToolName, + toolUseId: args.toolCall.id, + category: buildActionCategory(args.canonicalToolName), + ...(commentId ? { details: { commentId } } : {}), + }, + source: { + messageUuid: args.message.uuid, + filePath: args.filePath, + toolUseId: args.toolCall.id, + sourceOrder: args.sourceOrder, + }, + }; +} + +function collectTaskRecords(args: { + teamName: string; + task: TeamTask; + owner: string; + sessionId: string; + filePath: string; + messages: OpenCodeRuntimeTranscriptLogMessage[]; +}): BoardTaskActivityRecord[] { + const taskRefs = buildTaskRefSet(args.task); + if (taskRefs.size === 0) { + return []; + } + + const records: BoardTaskActivityRecord[] = []; + for (let index = 0; index < args.messages.length; index += 1) { + const message = args.messages[index]; + if (!message) { + continue; + } + + for (const toolCall of message.toolCalls) { + const canonicalToolName = canonicalizeAgentTeamsToolName(toolCall.name ?? '') + .trim() + .toLowerCase(); + if (!TASK_STALL_MARKER_TOOL_NAMES.has(canonicalToolName)) { + continue; + } + if (!markerInputReferencesTaskInTeam(toolCall.input, args.teamName, taskRefs)) { + continue; + } + + records.push( + buildRecord({ + teamName: args.teamName, + task: args.task, + owner: args.owner, + sessionId: args.sessionId, + message, + toolCall, + sourceOrder: index + 1, + filePath: args.filePath, + canonicalToolName, + }) + ); + } + } + + return records; +} + +function groupOpenCodeTasksByOwner( + tasks: TeamTask[], + providerByMemberName: Map +): Map { + const grouped = new Map(); + for (const task of tasks) { + const owner = task.owner?.trim(); + if (!owner) { + continue; + } + const provider = providerByMemberName.get(normalizeMemberNameKey(owner) ?? ''); + if (provider !== 'opencode') { + continue; + } + + const existing = grouped.get(owner) ?? []; + existing.push(task); + grouped.set(owner, existing); + } + return grouped; +} + +export class OpenCodeTaskStallEvidenceSource { + constructor( + private readonly runtimeBridge: RuntimeBridgeLike = new ClaudeMultimodelBridgeService(), + private readonly binaryResolver: BinaryResolverLike = ClaudeBinaryResolver + ) {} + + async readEvidence(args: { + teamName: string; + tasks: TeamTask[]; + providerByMemberName: Map; + }): Promise { + const tasksByOwner = groupOpenCodeTasksByOwner(args.tasks, args.providerByMemberName); + if (tasksByOwner.size === 0) { + return emptyEvidence(); + } + + const binaryPath = await this.binaryResolver.resolve(); + if (!binaryPath) { + return emptyEvidence(); + } + + const evidence = emptyEvidence(); + for (const [owner, tasks] of tasksByOwner.entries()) { + const transcript = await this.runtimeBridge + .getOpenCodeTranscript(binaryPath, { + teamId: args.teamName, + memberName: owner, + limit: OPENCODE_STALL_TRANSCRIPT_LIMIT, + }) + .catch(() => null); + const messages = transcript?.logProjection?.messages ?? []; + if (messages.length === 0) { + continue; + } + + const filePath = buildSyntheticFilePath(args.teamName, owner); + const exactRows = messages + .map((message, index) => toExactRow(message, filePath, index + 1)) + .filter((row): row is TeamTaskStallExactRow => row !== null); + if (exactRows.length > 0) { + evidence.exactRowsByFilePath.set(filePath, exactRows); + } + + const sessionId = transcript?.sessionId ?? messages[0]?.sessionId ?? filePath; + for (const task of tasks) { + const records = collectTaskRecords({ + teamName: args.teamName, + task, + owner, + sessionId, + filePath, + messages, + }); + if (records.length > 0) { + evidence.recordsByTaskId.set(task.id, records); + } + } + } + + return evidence; + } +} diff --git a/src/main/services/team/stallMonitor/TaskProgressSignalClassifier.ts b/src/main/services/team/stallMonitor/TaskProgressSignalClassifier.ts new file mode 100644 index 00000000..9a531026 --- /dev/null +++ b/src/main/services/team/stallMonitor/TaskProgressSignalClassifier.ts @@ -0,0 +1,264 @@ +import { stripAgentBlocks } from '@shared/constants/agentBlocks'; + +import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; +import type { TaskComment, TeamTask } from '@shared/types'; + +export type TaskProgressSignal = + | 'strong_progress' + | 'weak_start_only' + | 'blocker_or_clarification' + | 'terminal_progress' + | 'unknown'; + +export interface TaskProgressTouchClassification { + signal: TaskProgressSignal; + reason: string; +} + +const FILE_EXTENSIONS = [ + '.ts', + '.tsx', + '.js', + '.jsx', + '.cts', + '.mts', + '.ctsx', + '.mtsx', + '.json', + '.md', + '.css', + '.scss', + '.py', + '.go', + '.rs', + '.java', + '.kt', + '.swift', + '.yaml', + '.yml', + '.toml', + '.lock', + '.sh', + '.sql', +] as const; + +const TEST_OR_BUILD_KEYWORDS = [ + 'test', + 'tests', + 'tested', + 'testing', + 'vitest', + 'jest', + 'playwright', + 'pnpm', + 'npm', + 'bun', + 'build', + 'typecheck', + 'lint', + 'passed', + 'failed', + 'green', + 'red', + 'error', + 'exception', + 'stack trace', + 'тСст', + 'сборк', + 'Π»ΠΈΠ½Ρ‚', + 'ошибк', + 'ΡƒΠΏΠ°Π»', + 'ΠΏΡ€ΠΎΡˆΠ΅Π»', + 'ΠΏΡ€ΠΎΡˆΡ‘Π»', +] as const; + +const SUBSTANTIVE_WORK_KEYWORDS = [ + 'implemented', + 'fixed', + 'added', + 'updated', + 'changed', + 'removed', + 'found', + 'verified', + 'confirmed', + 'completed', + 'created', + 'refactored', + 'patched', + 'root cause', + 'next step', + 'исправ', + 'Π΄ΠΎΠ±Π°Π²', + 'ΠΎΠ±Π½ΠΎΠ²', + 'ΠΈΠ·ΠΌΠ΅Π½', + 'ΡƒΠ΄Π°Π»', + 'нашСл', + 'Π½Π°ΡˆΡ‘Π»', + 'ΠΏΠΎΠ΄Ρ‚Π²Π΅Ρ€Π΄', + 'Π³ΠΎΡ‚ΠΎΠ²ΠΎ', + 'сдСлал', + 'сдСлана', + 'ΠΏΡ€ΠΈΡ‡ΠΈΠ½', + 'ΡΠ»Π΅Π΄ΡƒΡŽΡ‰', +] as const; + +const BLOCKER_OR_CLARIFICATION_KEYWORDS = [ + 'blocked', + 'blocker', + 'cannot', + "can't", + 'need', + 'needs', + 'waiting', + 'clarification', + 'question', + 'permission', + 'access denied', + 'not enough context', + 'Π½Π΅ ΠΌΠΎΠ³Ρƒ', + 'Π½Π΅ получаСтся', + 'Π½ΡƒΠΆΠ½', + 'ΠΆΠ΄Ρƒ', + 'Π±Π»ΠΎΠΊ', + 'ΡƒΡ‚ΠΎΡ‡Π½', + 'вопрос', + 'Π½Π΅Ρ‚ доступа', + 'нСдостаточно контСкст', +] as const; + +const WEAK_START_ONLY_PHRASES = [ + 'Π½Π°Ρ‡ΠΈΠ½Π°ΡŽ', + 'Π½Π°Ρ‡ΠΈΠ½Π°ΡŽ Ρ€Π°Π±ΠΎΡ‚Ρƒ', + 'Π½Π°Ρ‡Π½Ρƒ', + 'ΠΏΡ€ΠΈΡΡ‚ΡƒΠΏΠ°ΡŽ', + 'ΠΏΡ€ΠΈΡΡ‚ΡƒΠΏΠ°ΡŽ ΠΊ Ρ€Π°Π±ΠΎΡ‚Π΅', + 'Π±Π΅Ρ€Ρƒ Π² Ρ€Π°Π±ΠΎΡ‚Ρƒ', + 'ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŽ', + 'сСйчас ΠΏΡ€ΠΎΠ²Π΅Ρ€ΡŽ', + 'ΠΏΠΎΡΠΌΠΎΡ‚Ρ€ΡŽ', + 'Ρ€Π°Π·Π±Π΅Ρ€ΡƒΡΡŒ', + 'Π³ΠΎΡ‚ΠΎΠ² ΠΏΡ€ΠΈΡΡ‚ΡƒΠΏΠΈΡ‚ΡŒ', + 'Π³ΠΎΡ‚ΠΎΠ²Π° ΠΏΡ€ΠΈΡΡ‚ΡƒΠΏΠΈΡ‚ΡŒ', + 'Π³ΠΎΡ‚ΠΎΠ² ΠΊ Ρ€Π°Π±ΠΎΡ‚Π΅', + 'Π³ΠΎΡ‚ΠΎΠ²Π° ΠΊ Ρ€Π°Π±ΠΎΡ‚Π΅', + 'will start', + 'starting work', + 'starting', + 'taking this', + "i'll start", + 'i’ll start', + 'i will start', + 'i am starting', + "i'll check", + 'i’ll check', + 'i will check', + 'checking now', + 'on it', +] as const; + +function normalizeCommentText(text: string): string { + return stripAgentBlocks(text).replace(/\s+/g, ' ').trim(); +} + +function includesAnyKeyword(text: string, keywords: readonly string[]): boolean { + return keywords.some((keyword) => text.includes(keyword)); +} + +function containsTaskOrIssueRef(text: string): boolean { + return text.includes('task-') || /#[a-f0-9]{6,}/i.test(text); +} + +function containsConcreteFileOrPath(text: string): boolean { + const parts = text.split(/\s+/); + return ( + parts.some( + (part) => part.startsWith('./') || part.startsWith('../') || part.startsWith('~/') + ) || + parts.some((part) => part.includes('/') && /[a-z0-9_]/i.test(part)) || + FILE_EXTENSIONS.some((extension) => text.includes(extension)) + ); +} + +function isWeakStartOnly(text: string): boolean { + const normalized = text + .replace(/[.!…\s]+$/g, '') + .replace(/^я\s+/, '') + .trim(); + return WEAK_START_ONLY_PHRASES.includes(normalized as (typeof WEAK_START_ONLY_PHRASES)[number]); +} + +function isConcreteProgress(text: string): boolean { + return ( + containsConcreteFileOrPath(text) || + containsTaskOrIssueRef(text) || + includesAnyKeyword(text, TEST_OR_BUILD_KEYWORDS) || + includesAnyKeyword(text, SUBSTANTIVE_WORK_KEYWORDS) + ); +} + +function classifyTaskCommentText(text: string): TaskProgressTouchClassification { + const normalized = normalizeCommentText(text); + if (!normalized) { + return { signal: 'unknown', reason: 'comment_text_empty' }; + } + + const lowerText = normalized.toLowerCase(); + + if (lowerText.includes('?') || includesAnyKeyword(lowerText, BLOCKER_OR_CLARIFICATION_KEYWORDS)) { + return { + signal: 'blocker_or_clarification', + reason: 'comment_mentions_blocker_or_clarification', + }; + } + + if (isConcreteProgress(lowerText)) { + return { signal: 'strong_progress', reason: 'comment_contains_concrete_progress' }; + } + + if (lowerText.length <= 120 && isWeakStartOnly(lowerText)) { + return { signal: 'weak_start_only', reason: 'comment_is_start_only' }; + } + + return { signal: 'unknown', reason: 'comment_progress_signal_unclear' }; +} + +export function getTaskCommentForActivityRecord( + task: TeamTask, + record: BoardTaskActivityRecord +): TaskComment | null { + const commentId = record.action?.details?.commentId?.trim(); + if (!commentId) { + return null; + } + return task.comments?.find((comment) => comment.id === commentId) ?? null; +} + +export function classifyTaskProgressTouch(args: { + task: TeamTask; + record: BoardTaskActivityRecord; +}): TaskProgressTouchClassification { + const toolName = args.record.action?.canonicalToolName; + if (toolName === 'task_start' || toolName === 'task_set_status') { + return { signal: 'strong_progress', reason: `${toolName}_is_authoritative_touch` }; + } + if (toolName === 'task_complete') { + return { signal: 'terminal_progress', reason: 'task_complete_is_terminal' }; + } + if (toolName === 'task_set_clarification') { + return { + signal: 'blocker_or_clarification', + reason: 'task_set_clarification_is_blocker_signal', + }; + } + if (toolName !== 'task_add_comment') { + return { signal: 'unknown', reason: 'tool_is_not_classified_for_task_progress' }; + } + + const comment = getTaskCommentForActivityRecord(args.task, args.record); + if (!comment) { + return { signal: 'unknown', reason: 'task_comment_text_unavailable' }; + } + + return classifyTaskCommentText(comment.text); +} diff --git a/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts b/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts index 5667929b..8a8a4e15 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallJournal.ts @@ -24,6 +24,7 @@ export class TeamTaskStallJournal { teamName: string; evaluations: TaskStallEvaluation[]; activeTaskIds: string[]; + scopeTaskIds?: string[]; now: string; }): Promise { const filePath = this.getFilePath(args.teamName); @@ -48,8 +49,12 @@ export class TeamTaskStallJournal { ); const activeTaskIdSet = new Set(args.activeTaskIds); + const scopeTaskIdSet = args.scopeTaskIds ? new Set(args.scopeTaskIds) : null; for (let i = entries.length - 1; i >= 0; i -= 1) { const entry = entries[i]; + if (scopeTaskIdSet && !scopeTaskIdSet.has(entry.taskId)) { + continue; + } if (!activeTaskIdSet.has(entry.taskId) || !candidateByEpoch.has(entry.epochKey)) { entries.splice(i, 1); } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts b/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts index a1b12321..02b197ac 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallMonitor.ts @@ -5,8 +5,10 @@ import { getTeamTaskStallActivationGraceMs, getTeamTaskStallScanIntervalMs, getTeamTaskStallStartupGraceMs, + isOpenCodeTaskStallRemediationEnabled, isTeamTaskStallAlertsEnabled, isTeamTaskStallMonitorEnabled, + isTeamTaskStallScannerEnabled, } from './featureGates'; import type { ActiveTeamRegistry } from './ActiveTeamRegistry'; @@ -24,6 +26,11 @@ interface TeamObservationState { lastActivationAtMs: number; } +function unrefBackgroundTimer(timer: ReturnType): void { + const maybeTimer = timer as { unref?: () => void }; + maybeTimer.unref?.(); +} + export class TeamTaskStallMonitor { private scanTimer: ReturnType | null = null; private nudgeTimer: ReturnType | null = null; @@ -40,7 +47,7 @@ export class TeamTaskStallMonitor { ) {} start(): void { - if (!isTeamTaskStallMonitorEnabled()) { + if (!isTeamTaskStallScannerEnabled()) { logger.debug('Task stall monitor disabled by feature gate'); return; } @@ -66,10 +73,10 @@ export class TeamTaskStallMonitor { } noteTeamChange(event: TeamChangeEvent): void { - this.registry.noteTeamChange(event); - if (!isTeamTaskStallMonitorEnabled()) { + if (!isTeamTaskStallScannerEnabled()) { return; } + this.registry.noteTeamChange(event); if ( event.type === 'member-spawn' || @@ -101,6 +108,7 @@ export class TeamTaskStallMonitor { this.scanTimer = null; void this.runScan(); }, delayMs); + unrefBackgroundTimer(this.scanTimer); } private scheduleNudgedScan(): void { @@ -111,6 +119,7 @@ export class TeamTaskStallMonitor { this.nudgeTimer = null; void this.runScan(); }, 5_000); + unrefBackgroundTimer(this.nudgeTimer); } private async runScan(): Promise { @@ -177,13 +186,21 @@ export class TeamTaskStallMonitor { evaluations.push(this.policy.evaluateReview({ now, task, snapshot })); } + const fullMonitorEnabled = isTeamTaskStallMonitorEnabled(); + const openCodeRemediationEnabled = isOpenCodeTaskStallRemediationEnabled(); + const openCodeOnlyMode = openCodeRemediationEnabled && !fullMonitorEnabled; + const scopedTaskIds = openCodeOnlyMode ? this.getOpenCodeOwnedTaskIds(snapshot) : undefined; + const journalEvaluations = openCodeOnlyMode + ? evaluations.filter((evaluation) => this.isOpenCodeOwnerWorkEvaluation(snapshot, evaluation)) + : evaluations; const activeTaskIds = [ ...new Set([...snapshot.inProgressTasks, ...snapshot.reviewOpenTasks].map((task) => task.id)), ]; const readyEvaluations = await this.journal.reconcileScan({ teamName, - evaluations, + evaluations: journalEvaluations, activeTaskIds, + ...(scopedTaskIds ? { scopeTaskIds: scopedTaskIds } : {}), now: now.toISOString(), }); @@ -195,14 +212,31 @@ export class TeamTaskStallMonitor { return; } - if (!isTeamTaskStallAlertsEnabled()) { + const alertedEpochKeys = new Set(); + if (openCodeRemediationEnabled) { + const remediatedAlerts = await this.notifier.notifyOpenCodeOwners(teamName, alerts); + for (const alert of remediatedAlerts) { + alertedEpochKeys.add(alert.epochKey); + } + } + + const leadFallbackAlerts = alerts.filter((alert) => !alertedEpochKeys.has(alert.epochKey)); + if (leadFallbackAlerts.length > 0 && isTeamTaskStallAlertsEnabled()) { + await this.notifier.notifyLead(teamName, leadFallbackAlerts); + for (const alert of leadFallbackAlerts) { + alertedEpochKeys.add(alert.epochKey); + } + } + + if (alertedEpochKeys.size === 0) { logger.debug(`Task stall monitor shadow-ready alerts for ${teamName}: ${alerts.length}`); return; } - await this.notifier.notifyLead(teamName, alerts); await Promise.all( - alerts.map((alert) => this.journal.markAlerted(teamName, alert.epochKey, now.toISOString())) + alerts + .filter((alert) => alertedEpochKeys.has(alert.epochKey)) + .map((alert) => this.journal.markAlerted(teamName, alert.epochKey, now.toISOString())) ); } @@ -227,6 +261,9 @@ export class TeamTaskStallMonitor { } const displayId = getTaskDisplayId(task); + const ownerProviderId = task.owner + ? snapshot.providerByMemberName.get(task.owner.trim().toLowerCase()) + : undefined; return { teamName: snapshot.teamName, taskId: task.id, @@ -234,8 +271,11 @@ export class TeamTaskStallMonitor { subject: task.subject, branch: evaluation.branch, signal: evaluation.signal, + ...(evaluation.progressSignal ? { progressSignal: evaluation.progressSignal } : {}), reason: evaluation.reason, epochKey: evaluation.epochKey, + ...(task.owner ? { owner: task.owner } : {}), + ...(ownerProviderId ? { ownerProviderId } : {}), taskRef: { taskId: task.id, displayId, @@ -243,4 +283,37 @@ export class TeamTaskStallMonitor { }, }; } + + private isOpenCodeOwnerWorkEvaluation( + snapshot: Awaited>, + evaluation: TaskStallEvaluation + ): boolean { + if ( + !snapshot || + evaluation.status !== 'alert' || + evaluation.branch !== 'work' || + !evaluation.taskId + ) { + return false; + } + + const task = snapshot.allTasksById.get(evaluation.taskId); + const ownerProviderId = task?.owner + ? snapshot.providerByMemberName.get(task.owner.trim().toLowerCase()) + : undefined; + return ownerProviderId === 'opencode'; + } + + private getOpenCodeOwnedTaskIds( + snapshot: NonNullable>> + ): string[] { + return [...snapshot.allTasksById.values()] + .filter((task) => { + const ownerProviderId = task.owner + ? snapshot.providerByMemberName.get(task.owner.trim().toLowerCase()) + : undefined; + return ownerProviderId === 'opencode'; + }) + .map((task) => task.id); + } } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts index c86dc6a6..e5b3adb5 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallNotifier.ts @@ -1,7 +1,24 @@ +import { createLogger } from '@shared/utils/logger'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; +import { TeamInboxReader } from '../TeamInboxReader'; +import { TeamInboxWriter } from '../TeamInboxWriter'; + import type { TeamDataService } from '../TeamDataService'; +import type { TeamProvisioningService } from '../TeamProvisioningService'; import type { TaskStallAlert } from './TeamTaskStallTypes'; +import type { SendMessageRequest } from '@shared/types'; + +const logger = createLogger('Service:TeamTaskStallNotifier'); + +type OpenCodeTaskStallRelayService = Pick< + TeamProvisioningService, + 'relayOpenCodeMemberInboxMessages' +>; +type OpenCodeTaskStallRelayResult = Awaited< + ReturnType +>; +type OpenCodeTaskStallDelivery = NonNullable; function buildLeadAlertText(alerts: TaskStallAlert[]): string { return alerts @@ -12,9 +29,40 @@ function buildLeadAlertText(alerts: TaskStallAlert[]): string { .join('\n'); } +function buildOpenCodeOwnerNudgeText(alert: TaskStallAlert): string { + const taskLabel = formatTaskDisplayLabel({ + id: alert.taskId, + displayId: alert.displayId, + }); + return [ + `Task ${taskLabel} may be stalled after a low-signal progress update.`, + 'Continue the task now. If blocked, add a concrete task comment explaining the blocker and needed input. If done, add a final task comment with the result and complete the task.', + 'Do not send acknowledgement-only replies.', + ].join('\n'); +} + +function isOpenCodeDeliveryAccepted(delivery: OpenCodeTaskStallDelivery): boolean { + if (delivery.queuedBehindMessageId) { + return false; + } + if (delivery.accepted === true) { + return true; + } + if (delivery.responsePending === true) { + return false; + } + if (delivery.delivered === true) { + return true; + } + return false; +} + export class TeamTaskStallNotifier { constructor( - private readonly teamDataService: Pick + private readonly teamDataService: Pick, + private readonly teamProvisioningService?: OpenCodeTaskStallRelayService, + private readonly inboxReader: Pick = new TeamInboxReader(), + private readonly inboxWriter: Pick = new TeamInboxWriter() ) {} async notifyLead(teamName: string, alerts: TaskStallAlert[]): Promise { @@ -29,4 +77,108 @@ export class TeamTaskStallNotifier { taskRefs: alerts.map((alert) => alert.taskRef), }); } + + private async ensureOpenCodeOwnerNudgeInboxMessage(args: { + teamName: string; + alert: TaskStallAlert; + messageId: string; + text: string; + timestamp: string; + }): Promise { + const owner = args.alert.owner?.trim(); + if (!owner) { + return false; + } + + try { + const existing = await this.inboxReader.getMessagesFor(args.teamName, owner); + if (existing.some((message) => message.messageId === args.messageId)) { + return true; + } + + const request: SendMessageRequest = { + member: owner, + from: 'system', + to: owner, + messageId: args.messageId, + timestamp: args.timestamp, + summary: 'Potential stalled task', + text: args.text, + taskRefs: [args.alert.taskRef], + actionMode: 'do', + source: 'system_notification', + }; + await this.inboxWriter.sendMessage(args.teamName, request); + return true; + } catch (error) { + logger.warn( + `OpenCode task stall remediation inbox write failed for ${args.teamName}/${args.alert.taskId}: ${String( + error + )}` + ); + return false; + } + } + + async notifyOpenCodeOwners( + teamName: string, + alerts: TaskStallAlert[] + ): Promise { + if (!this.teamProvisioningService || alerts.length === 0) { + return []; + } + + const deliveredAlerts: TaskStallAlert[] = []; + for (const alert of alerts) { + if (alert.branch !== 'work' || alert.ownerProviderId !== 'opencode' || !alert.owner?.trim()) { + continue; + } + + try { + const messageId = `task-stall:${teamName}:${alert.taskId}:${alert.epochKey}`; + const timestamp = new Date().toISOString(); + const text = buildOpenCodeOwnerNudgeText(alert); + const inboxReady = await this.ensureOpenCodeOwnerNudgeInboxMessage({ + teamName, + alert, + messageId, + text, + timestamp, + }); + if (!inboxReady) { + continue; + } + + const relay = await this.teamProvisioningService.relayOpenCodeMemberInboxMessages( + teamName, + alert.owner, + { + onlyMessageId: messageId, + source: 'watchdog', + deliveryMetadata: { + replyRecipient: 'user', + actionMode: 'do', + taskRefs: [alert.taskRef], + }, + } + ); + const delivery = relay.lastDelivery; + if (delivery && isOpenCodeDeliveryAccepted(delivery)) { + deliveredAlerts.push(alert); + continue; + } + logger.debug( + `OpenCode task stall remediation was not accepted for ${teamName}/${alert.taskId}: ${ + delivery?.reason ?? relay.diagnostics?.[0] ?? 'unknown' + }` + ); + } catch (error) { + logger.warn( + `OpenCode task stall remediation failed for ${teamName}/${alert.taskId}: ${String(error)}` + ); + } + } + + return deliveredAlerts; + } } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts index c09a494d..fe202870 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallPolicy.ts @@ -1,3 +1,6 @@ +import { getOpenCodeWeakStartStallThresholdMs } from './featureGates'; +import { classifyTaskProgressTouch, type TaskProgressSignal } from './TaskProgressSignalClassifier'; + import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; import type { ReviewTaskContext, @@ -282,10 +285,26 @@ function buildEpochKey( ].join(':'); } +function buildOpenCodeNoProgressEpochKey(args: { + task: TeamTask; + intervalStartedAt: string; + owner: string; +}): string { + return [ + args.task.id, + 'work', + 'opencode_no_owner_progress', + args.owner, + args.intervalStartedAt, + args.task.updatedAt ?? args.task.createdAt ?? 'unknown', + ].join(':'); +} + function buildAlertEvaluation(args: { task: TeamTask; branch: TaskStallBranch; signal: TaskStallSignal; + progressSignal?: TaskProgressSignal; touch: BoardTaskActivityRecord; reason: string; }): TaskStallEvaluation { @@ -294,11 +313,41 @@ function buildAlertEvaluation(args: { taskId: args.task.id, branch: args.branch, signal: args.signal, + ...(args.progressSignal ? { progressSignal: args.progressSignal } : {}), epochKey: buildEpochKey(args.task, args.branch, args.signal, args.touch), reason: args.reason, }; } +function buildOpenCodeNoProgressAlertEvaluation(args: { + task: TeamTask; + owner: string; + intervalStartedAt: string; + reason: string; +}): TaskStallEvaluation { + return { + status: 'alert', + taskId: args.task.id, + branch: 'work', + signal: 'mid_turn_after_touch', + progressSignal: 'unknown', + epochKey: buildOpenCodeNoProgressEpochKey(args), + reason: args.reason, + }; +} + +function normalizeMemberNameKey(name: string | undefined): string | null { + const normalized = name?.trim().toLowerCase(); + return normalized ? normalized : null; +} + +function resolveOwnerProviderId( + snapshot: TeamTaskStallSnapshot, + owner: string | undefined +): string | null { + return snapshot.providerByMemberName.get(normalizeMemberNameKey(owner) ?? '') ?? null; +} + export class TeamTaskStallPolicy { evaluateWork(args: { now: Date; @@ -338,7 +387,25 @@ export class TeamTaskStallPolicy { } const records = snapshot.recordsByTaskId.get(task.id) ?? []; + const ownerProviderId = resolveOwnerProviderId(snapshot, task.owner); + const isOpenCodeOwner = ownerProviderId === 'opencode'; if (records.length === 0 && !snapshot.freshnessByTaskId.has(task.id)) { + if (isOpenCodeOwner) { + const elapsedMs = args.now.getTime() - Date.parse(openWorkInterval.startedAt); + if (elapsedMs >= getOpenCodeWeakStartStallThresholdMs()) { + return buildOpenCodeNoProgressAlertEvaluation({ + task, + owner: task.owner, + intervalStartedAt: openWorkInterval.startedAt, + reason: 'Potential OpenCode task stall without owner progress evidence.', + }); + } + return skip( + task.id, + 'OpenCode task has no owner progress evidence yet but is below the stall threshold', + 'below_threshold' + ); + } return skip( task.id, 'Task run is not instrumented enough for stall evaluation', @@ -360,6 +427,22 @@ export class TeamTaskStallPolicy { })(); if (!workContext) { + if (isOpenCodeOwner) { + const elapsedMs = args.now.getTime() - Date.parse(openWorkInterval.startedAt); + if (elapsedMs >= getOpenCodeWeakStartStallThresholdMs()) { + return buildOpenCodeNoProgressAlertEvaluation({ + task, + owner: task.owner, + intervalStartedAt: openWorkInterval.startedAt, + reason: 'Potential OpenCode task stall without owner work touch.', + }); + } + return skip( + task.id, + 'OpenCode task has no owner work touch yet but is below the stall threshold', + 'below_threshold' + ); + } return skip( task.id, 'No positive work touch found in current work interval', @@ -383,8 +466,16 @@ export class TeamTaskStallPolicy { return skip(task.id, 'Post-touch state is ambiguous', 'ambiguous_state'); } + const progressClassification = classifyTaskProgressTouch({ + task, + record: workContext.lastMeaningfulTouch, + }); + const isOpenCodeWeakStartOnly = + ownerProviderId === 'opencode' && progressClassification.signal === 'weak_start_only'; const elapsedMs = args.now.getTime() - Date.parse(workContext.lastMeaningfulTouchAt); - const thresholdMs = WORK_THRESHOLDS_MS[signal]; + const thresholdMs = isOpenCodeWeakStartOnly + ? getOpenCodeWeakStartStallThresholdMs() + : WORK_THRESHOLDS_MS[signal]; if (elapsedMs < thresholdMs) { return skip( task.id, @@ -397,8 +488,11 @@ export class TeamTaskStallPolicy { task, branch: 'work', signal, + progressSignal: progressClassification.signal, touch: workContext.lastMeaningfulTouch, - reason: `Potential work stall after ${signal.replaceAll('_', ' ')}.`, + reason: isOpenCodeWeakStartOnly + ? 'Potential work stall after weak start-only task comment.' + : `Potential work stall after ${signal.replaceAll('_', ' ')}.`, }); } diff --git a/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts index 810f9637..336c6df3 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.ts @@ -1,24 +1,67 @@ +import { + inferTeamProviderIdFromModel, + normalizeOptionalTeamProviderId, +} from '@shared/utils/teamProvider'; + import { BoardTaskActivityTranscriptReader } from '../taskLogs/activity/BoardTaskActivityTranscriptReader'; import { isBoardTaskActivityReadEnabled } from '../taskLogs/activity/featureGates'; import { TeamTranscriptSourceLocator } from '../taskLogs/discovery/TeamTranscriptSourceLocator'; import { isBoardTaskExactLogsReadEnabled } from '../taskLogs/exact/featureGates'; import { TeamKanbanManager } from '../TeamKanbanManager'; +import { TeamMembersMetaStore } from '../TeamMembersMetaStore'; import { TeamTaskReader } from '../TeamTaskReader'; import { BoardTaskActivityBatchIndexer } from './BoardTaskActivityBatchIndexer'; +import { OpenCodeTaskStallEvidenceSource } from './OpenCodeTaskStallEvidenceSource'; import { buildResolvedReviewerIndex } from './reviewerResolution'; import { TeamTaskLogFreshnessReader } from './TeamTaskLogFreshnessReader'; import { TeamTaskStallExactRowReader } from './TeamTaskStallExactRowReader'; import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; -import type { TeamTaskStallSnapshot } from './TeamTaskStallTypes'; -import type { TeamConfig, TeamTask } from '@shared/types'; +import type { TeamTaskStallExactRow, TeamTaskStallSnapshot } from './TeamTaskStallTypes'; +import type { TeamConfig, TeamMember, TeamProviderId, TeamTask } from '@shared/types'; function resolveLeadNameFromConfig(config: TeamConfig): string { const lead = config.members?.find((member) => member.role?.toLowerCase().includes('lead')); return lead?.name ?? config.members?.[0]?.name ?? 'team-lead'; } +function normalizeMemberNameKey(name: string | undefined): string | null { + const normalized = name?.trim().toLowerCase(); + return normalized ? normalized : null; +} + +function resolveMemberProvider(member: TeamMember): TeamProviderId | undefined { + const legacyProvider = (member as { provider?: unknown }).provider; + return ( + normalizeOptionalTeamProviderId(member.providerId) ?? + normalizeOptionalTeamProviderId(legacyProvider) ?? + inferTeamProviderIdFromModel(member.model) + ); +} + +function buildProviderByMemberName(args: { + configMembers: TeamMember[]; + metaMembers: TeamMember[]; +}): Map { + const providerByMemberName = new Map(); + for (const member of args.configMembers) { + const memberName = normalizeMemberNameKey(member.name); + const providerId = resolveMemberProvider(member); + if (memberName && providerId) { + providerByMemberName.set(memberName, providerId); + } + } + for (const member of args.metaMembers) { + const memberName = normalizeMemberNameKey(member.name); + const providerId = resolveMemberProvider(member); + if (memberName && providerId) { + providerByMemberName.set(memberName, providerId); + } + } + return providerByMemberName; +} + export class TeamTaskStallSnapshotSource { constructor( private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), @@ -27,7 +70,9 @@ export class TeamTaskStallSnapshotSource { private readonly transcriptReader: BoardTaskActivityTranscriptReader = new BoardTaskActivityTranscriptReader(), private readonly activityBatchIndexer: BoardTaskActivityBatchIndexer = new BoardTaskActivityBatchIndexer(), private readonly freshnessReader: TeamTaskLogFreshnessReader = new TeamTaskLogFreshnessReader(), - private readonly exactRowReader: TeamTaskStallExactRowReader = new TeamTaskStallExactRowReader() + private readonly exactRowReader: TeamTaskStallExactRowReader = new TeamTaskStallExactRowReader(), + private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), + private readonly openCodeEvidenceSource: OpenCodeTaskStallEvidenceSource = new OpenCodeTaskStallEvidenceSource() ) {} async getSnapshot(teamName: string): Promise { @@ -36,10 +81,11 @@ export class TeamTaskStallSnapshotSource { return null; } - const [activeTasks, deletedTasks, kanbanState] = await Promise.all([ + const [activeTasks, deletedTasks, kanbanState, metaMembers] = await Promise.all([ this.taskReader.getTasks(teamName), this.taskReader.getDeletedTasks(teamName), this.kanbanManager.getState(teamName), + this.membersMetaStore.getMembers(teamName).catch(() => []), ]); const allTasks = [...activeTasks, ...deletedTasks]; const allTasksById = new Map(allTasks.map((task) => [task.id, task] as const)); @@ -50,6 +96,10 @@ export class TeamTaskStallSnapshotSource { const resolvedReviewersByTaskId = buildResolvedReviewerIndex(activeTasks, kanbanState); const activityReadsEnabled = isBoardTaskActivityReadEnabled(); const exactReadsEnabled = isBoardTaskExactLogsReadEnabled(); + const providerByMemberName = buildProviderByMemberName({ + configMembers: transcriptContext.config.members ?? [], + metaMembers, + }); let recordsByTaskId = new Map(); if ( @@ -70,7 +120,7 @@ export class TeamTaskStallSnapshotSource { relevantMonitorTasks, recordsByTaskId ); - const [freshnessByTaskId, exactRowsByFilePath] = await Promise.all([ + const [freshnessByTaskId, exactRowsByFilePath, openCodeEvidence] = await Promise.all([ this.freshnessReader.readSignals( transcriptContext.projectDir, relevantMonitorTasks.map((task) => task.id) @@ -78,7 +128,25 @@ export class TeamTaskStallSnapshotSource { exactReadsEnabled ? this.exactRowReader.parseFiles(relevantExactFiles) : Promise.resolve(new Map()), + activityReadsEnabled && exactReadsEnabled + ? this.openCodeEvidenceSource.readEvidence({ + teamName, + tasks: relevantMonitorTasks, + providerByMemberName, + }) + : Promise.resolve({ + recordsByTaskId: new Map(), + exactRowsByFilePath: new Map(), + }), ]); + const mergedRecordsByTaskId = this.mergeActivityRecords( + recordsByTaskId, + openCodeEvidence.recordsByTaskId + ); + const mergedExactRowsByFilePath = this.mergeExactRows( + exactRowsByFilePath, + openCodeEvidence.exactRowsByFilePath + ); return { teamName, @@ -95,12 +163,72 @@ export class TeamTaskStallSnapshotSource { inProgressTasks, reviewOpenTasks, resolvedReviewersByTaskId, - recordsByTaskId, + recordsByTaskId: mergedRecordsByTaskId, freshnessByTaskId, - exactRowsByFilePath, + exactRowsByFilePath: mergedExactRowsByFilePath, + providerByMemberName, }; } + private mergeActivityRecords( + base: Map, + extra: Map + ): Map { + if (extra.size === 0) { + return base; + } + + const merged = new Map(base); + for (const [taskId, records] of extra.entries()) { + const existing = merged.get(taskId) ?? []; + const seen = new Set(existing.map((record) => record.id)); + const next = [...existing]; + for (const record of records) { + if (!seen.has(record.id)) { + next.push(record); + seen.add(record.id); + } + } + next.sort((left, right) => { + const timeDiff = Date.parse(left.timestamp) - Date.parse(right.timestamp); + return timeDiff !== 0 ? timeDiff : left.source.sourceOrder - right.source.sourceOrder; + }); + merged.set(taskId, next); + } + return merged; + } + + private mergeExactRows( + base: Map, + extra: Map + ): Map { + if (extra.size === 0) { + return base; + } + + const merged = new Map(base); + for (const [filePath, rows] of extra.entries()) { + const existing = merged.get(filePath) ?? []; + const seen = new Set(existing.map((row) => `${row.messageUuid}:${row.sourceOrder}`)); + const next = [...existing]; + for (const row of rows) { + const key = `${row.messageUuid}:${row.sourceOrder}`; + if (!seen.has(key)) { + next.push(row); + seen.add(key); + } + } + next.sort((left, right) => { + const orderDiff = left.sourceOrder - right.sourceOrder; + return orderDiff !== 0 + ? orderDiff + : Date.parse(left.timestamp) - Date.parse(right.timestamp); + }); + merged.set(filePath, next); + } + return merged; + } + private collectRelevantExactFiles( inProgressTasks: TeamTask[], recordsByTaskId: Map diff --git a/src/main/services/team/stallMonitor/TeamTaskStallTypes.ts b/src/main/services/team/stallMonitor/TeamTaskStallTypes.ts index 46550e05..30c68581 100644 --- a/src/main/services/team/stallMonitor/TeamTaskStallTypes.ts +++ b/src/main/services/team/stallMonitor/TeamTaskStallTypes.ts @@ -1,6 +1,7 @@ import type { BoardTaskActivityRecord } from '../taskLogs/activity/BoardTaskActivityRecord'; +import type { TaskProgressSignal } from './TaskProgressSignalClassifier'; import type { ParsedMessage } from '@main/types'; -import type { TeamTask } from '@shared/types'; +import type { TeamProviderId, TeamTask } from '@shared/types'; export type TaskStallBranch = 'work' | 'review'; @@ -47,6 +48,7 @@ export interface TaskStallEvaluation { taskId?: string; branch?: TaskStallBranch; signal?: TaskStallSignal; + progressSignal?: TaskProgressSignal; epochKey?: string; reason: string; skipReason?: TaskStallSkipReason; @@ -91,6 +93,7 @@ export interface TeamTaskStallSnapshot { recordsByTaskId: Map; freshnessByTaskId: Map; exactRowsByFilePath: Map; + providerByMemberName: Map; } export interface WorkTaskContext { @@ -114,8 +117,11 @@ export interface TaskStallAlert { subject: string; branch: TaskStallBranch; signal: TaskStallSignal; + progressSignal?: TaskProgressSignal; reason: string; epochKey: string; + owner?: string; + ownerProviderId?: TeamProviderId; taskRef: { taskId: string; displayId: string; diff --git a/src/main/services/team/stallMonitor/featureGates.ts b/src/main/services/team/stallMonitor/featureGates.ts index f9c24682..d3c2c551 100644 --- a/src/main/services/team/stallMonitor/featureGates.ts +++ b/src/main/services/team/stallMonitor/featureGates.ts @@ -22,11 +22,25 @@ function readInt(value: string | undefined, defaultValue: number): number { } export function isTeamTaskStallMonitorEnabled(): boolean { - return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED, false); + // General stall monitor for all providers. When enabled, stalled work/review tasks are + // evaluated and routed to the normal alert pipeline. + return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED, true); +} + +export function isOpenCodeTaskStallRemediationEnabled(): boolean { + // OpenCode-specific enhancement. It can directly nudge the OpenCode task owner before + // falling back to the lead alert path. + return readEnabledFlag(process.env.CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED, true); +} + +export function isTeamTaskStallScannerEnabled(): boolean { + // The scanner must run for either full monitoring or OpenCode-only remediation mode. + return isTeamTaskStallMonitorEnabled() || isOpenCodeTaskStallRemediationEnabled(); } export function isTeamTaskStallAlertsEnabled(): boolean { - return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED, false); + // Lead/system notifications for alerts that are not handled by provider-specific remediation. + return readEnabledFlag(process.env.CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED, true); } export function getTeamTaskStallScanIntervalMs(): number { @@ -40,3 +54,8 @@ export function getTeamTaskStallStartupGraceMs(): number { export function getTeamTaskStallActivationGraceMs(): number { return readInt(process.env.CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS, 120_000); } + +export function getOpenCodeWeakStartStallThresholdMs(): number { + // Shorter OpenCode threshold for "started work" comments that do not contain concrete progress. + return readInt(process.env.CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS, 120_000); +} diff --git a/src/main/services/team/taskLogs/TranscriptSessionActorContext.ts b/src/main/services/team/taskLogs/TranscriptSessionActorContext.ts new file mode 100644 index 00000000..1ef5a5e7 --- /dev/null +++ b/src/main/services/team/taskLogs/TranscriptSessionActorContext.ts @@ -0,0 +1,108 @@ +interface SessionActorContextState { + agentId?: string; + agentName?: string; + isSidechain?: boolean; + agentIdAmbiguous: boolean; + agentNameAmbiguous: boolean; + isSidechainAmbiguous: boolean; +} + +function readNonEmptyString(value: unknown): string | undefined { + return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined; +} + +function hasBoolean(value: unknown): value is boolean { + return typeof value === 'boolean'; +} + +function cloneWithContext>( + record: T, + updates: Record +): T { + return { + ...record, + ...updates, + }; +} + +export class TranscriptSessionActorContextTracker { + private readonly contextsBySessionId = new Map(); + + remember(record: Record): void { + const sessionId = readNonEmptyString(record.sessionId); + if (!sessionId) { + return; + } + + const agentId = readNonEmptyString(record.agentId); + const agentName = readNonEmptyString(record.agentName); + const isSidechain = hasBoolean(record.isSidechain) ? record.isSidechain : undefined; + if (!agentId && !agentName && isSidechain === undefined) { + return; + } + + const current = this.contextsBySessionId.get(sessionId) ?? { + agentIdAmbiguous: false, + agentNameAmbiguous: false, + isSidechainAmbiguous: false, + }; + + const next: SessionActorContextState = { ...current }; + if (agentId) { + if (current.agentId && current.agentId !== agentId) { + next.agentIdAmbiguous = true; + } else { + next.agentId = agentId; + } + } + + if (agentName) { + if (current.agentName && current.agentName !== agentName) { + next.agentNameAmbiguous = true; + } else { + next.agentName = agentName; + } + } + + if (isSidechain !== undefined) { + if (current.isSidechain !== undefined && current.isSidechain !== isSidechain) { + next.isSidechainAmbiguous = true; + } else { + next.isSidechain = isSidechain; + } + } + + this.contextsBySessionId.set(sessionId, next); + } + + apply>(record: T): T { + const sessionId = readNonEmptyString(record.sessionId); + if (!sessionId) { + return record; + } + + const context = this.contextsBySessionId.get(sessionId); + if (!context) { + return record; + } + + const updates: Record = {}; + if (!readNonEmptyString(record.agentId) && context.agentId && !context.agentIdAmbiguous) { + updates.agentId = context.agentId; + } + + if (!readNonEmptyString(record.agentName) && context.agentName && !context.agentNameAmbiguous) { + updates.agentName = context.agentName; + } + + if ( + !hasBoolean(record.isSidechain) && + context.isSidechain !== undefined && + !context.isSidechainAmbiguous + ) { + updates.isSidechain = context.isSidechain; + } + + return Object.keys(updates).length > 0 ? cloneWithContext(record, updates) : record; + } +} diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts index f3ff491d..ea78adaa 100644 --- a/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityDetailService.ts @@ -183,7 +183,7 @@ function extractBoardToolOutputText( return null; } - const payload = parsedPayload as Record; + const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record); if (toolName === 'task_add_comment' || toolName === 'task_get_comment') { const comment = payload.comment as Record | undefined; if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { @@ -194,6 +194,22 @@ function extractBoardToolOutputText( return null; } +function unwrapAgentTeamsResponsePayload( + payload: Record +): Record { + const wrapperKey = Object.keys(payload).find( + (key) => key.startsWith('agent_teams_') && key.endsWith('_response') + ); + if (!wrapperKey) { + return payload; + } + + const nested = payload[wrapperKey]; + return typeof nested === 'object' && nested !== null && !Array.isArray(nested) + ? (nested as Record) + : payload; +} + function collectTextBlockText(value: unknown): string { if (!Array.isArray(value)) { return ''; @@ -252,9 +268,10 @@ function sanitizeToolResultContent( const parsedPayload = parseJsonLikeString(content.content); const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); if (typeof extractedText === 'string') { + const { is_error: _isError, ...rest } = content; return { - ...content, - content: [{ type: 'text', text: extractedText }], + ...rest, + content: extractedText, }; } return parsedPayload ? { ...content, content: '' } : cloneBlock(content); diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts index 01d780a3..93891bde 100644 --- a/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityRecordBuilder.ts @@ -51,6 +51,11 @@ function normalizeDisplayRef(value: string): string { return value.trim().toLowerCase(); } +function isConventionalLeadName(value: string): boolean { + const normalized = normalizeDisplayRef(value); + return normalized === 'team-lead' || normalized === 'lead'; +} + function looksLikeCanonicalTaskId(value: string): boolean { return CANONICAL_TASK_ID_PATTERN.test(value.trim()); } @@ -245,16 +250,17 @@ function resolveActivityActor(message: RawTaskActivityMessage): BoardTaskActivit typeof message.agentName === 'string' && message.agentName.trim().length > 0 ? message.agentName.trim() : undefined; + const role: BoardTaskActivityActor['role'] = memberName + ? isConventionalLeadName(memberName) + ? 'lead' + : 'member' + : message.isSidechain + ? 'member' + : 'unknown'; return { ...(memberName ? { memberName } : {}), - role: memberName - ? message.isSidechain - ? 'member' - : 'lead' - : message.isSidechain - ? 'member' - : 'unknown', + role, sessionId: message.sessionId, ...(message.agentId ? { agentId: message.agentId } : {}), isSidechain: message.isSidechain, diff --git a/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts b/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts index 64e92093..e2391082 100644 --- a/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts +++ b/src/main/services/team/taskLogs/activity/BoardTaskActivityTranscriptReader.ts @@ -10,6 +10,7 @@ import { type ParsedBoardTaskLink, type ParsedBoardTaskToolAction, } from '../contract/BoardTaskTranscriptContract'; +import { TranscriptSessionActorContextTracker } from '../TranscriptSessionActorContext'; import { BoardTaskActivityParseCache } from './BoardTaskActivityParseCache'; @@ -45,7 +46,7 @@ async function mapLimit( if (currentIndex >= items.length) { return; } - results[currentIndex] = await fn(items[currentIndex]!); + results[currentIndex] = await fn(items[currentIndex]); } }); await Promise.all(workers); @@ -56,6 +57,12 @@ function asRecord(value: unknown): Record | null { return value && typeof value === 'object' ? (value as Record) : null; } +function lineMayContainTaskActivityOrActorContext(line: string): boolean { + return ( + line.includes('"boardTaskLinks"') || line.includes('"agentName"') || line.includes('"agentId"') + ); +} + export class BoardTaskActivityTranscriptReader { private readonly cache = new BoardTaskActivityParseCache(); @@ -112,6 +119,7 @@ export class BoardTaskActivityTranscriptReader { private async parseFile(filePath: string): Promise { const results: RawTaskActivityMessage[] = []; + const actorContextTracker = new TranscriptSessionActorContextTracker(); const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, @@ -123,7 +131,7 @@ export class BoardTaskActivityTranscriptReader { for await (const line of rl) { if (!line.trim()) continue; lineCount += 1; - if (!line.includes('"boardTaskLinks"')) { + if (!lineMayContainTaskActivityOrActorContext(line)) { if (lineCount % 500 === 0) { await yieldToEventLoop(); } @@ -134,6 +142,11 @@ export class BoardTaskActivityTranscriptReader { const parsed = JSON.parse(line) as unknown; const record = asRecord(parsed); if (!record) continue; + actorContextTracker.remember(record); + + if (!line.includes('"boardTaskLinks"')) { + continue; + } const uuid = typeof record.uuid === 'string' ? record.uuid : ''; const sessionId = typeof record.sessionId === 'string' ? record.sessionId : ''; @@ -142,6 +155,7 @@ export class BoardTaskActivityTranscriptReader { const boardTaskLinks = parseBoardTaskLinks(record.boardTaskLinks); if (boardTaskLinks.length === 0) continue; + const contextRecord = actorContextTracker.apply(record); sourceOrder += 1; results.push({ @@ -149,9 +163,10 @@ export class BoardTaskActivityTranscriptReader { uuid, timestamp, sessionId, - agentId: typeof record.agentId === 'string' ? record.agentId : undefined, - agentName: typeof record.agentName === 'string' ? record.agentName : undefined, - isSidechain: record.isSidechain === true, + agentId: typeof contextRecord.agentId === 'string' ? contextRecord.agentId : undefined, + agentName: + typeof contextRecord.agentName === 'string' ? contextRecord.agentName : undefined, + isSidechain: contextRecord.isSidechain === true, boardTaskLinks, boardTaskToolActions: parseBoardTaskToolActions(record.boardTaskToolActions), sourceOrder, diff --git a/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts index 4df44881..dd4813e8 100644 --- a/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts +++ b/src/main/services/team/taskLogs/diagnostics/BoardTaskLogDiagnosticsService.ts @@ -170,6 +170,12 @@ function isEmptyToolPayload(value: unknown): boolean { return false; } +function asObjectRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + function collectEmptyPayloadExamples( stream: Awaited> ): BoardTaskLogDiagnosticExample[] { @@ -194,7 +200,7 @@ function collectEmptyPayloadExamples( }); } - const toolUseResult = message.toolUseResult; + const toolUseResult = asObjectRecord(message.toolUseResult); if (!toolUseResult) { continue; } diff --git a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts index 4cdddef0..e133b615 100644 --- a/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts +++ b/src/main/services/team/taskLogs/discovery/TeamTranscriptSourceLocator.ts @@ -1,8 +1,7 @@ +import { createLogger } from '@shared/utils/logger'; import * as fs from 'fs/promises'; import * as path from 'path'; -import { createLogger } from '@shared/utils/logger'; - import { TeamTranscriptProjectResolver } from '../../TeamTranscriptProjectResolver'; import type { TeamConfig } from '@shared/types'; @@ -35,7 +34,7 @@ async function mapLimit( if (currentIndex >= items.length) { return; } - results[currentIndex] = await fn(items[currentIndex]!); + results[currentIndex] = await fn(items[currentIndex]); } }); await Promise.all(workers); diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts index c64f71f1..ff5513b5 100644 --- a/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogDetailSelector.ts @@ -16,11 +16,150 @@ interface TentativeFilteredMessage { matchedToolUseId?: string; } -function isToolAnchoredOutputMessage( - message: ParsedMessage, - toolUseId: string | undefined -): boolean { - return Boolean(toolUseId && message.sourceToolUseID === toolUseId); +interface ToolAnchorScope { + toolUseId?: string; + assistantUuids: Set; + outputMessageUuids: Set; +} + +function messageHasToolUse(message: ParsedMessage, toolUseId: string | undefined): boolean { + if (!toolUseId || message.type !== 'assistant' || typeof message.content === 'string') { + return false; + } + return message.content.some((block) => block.type === 'tool_use' && block.id === toolUseId); +} + +function messageHasToolResult(message: ParsedMessage, toolUseId: string | undefined): boolean { + if (!toolUseId || typeof message.content === 'string') { + return false; + } + return message.content.some( + (block) => block.type === 'tool_result' && block.tool_use_id === toolUseId + ); +} + +function buildToolAnchorScope(args: { + candidate: BoardTaskExactLogBundleCandidate; + parsedMessages: ParsedMessage[]; + explicitMessageIds: Set; +}): ToolAnchorScope { + const toolUseId = + args.candidate.anchor.kind === 'tool' ? args.candidate.anchor.toolUseId : undefined; + const assistantUuids = new Set(); + const outputMessageUuids = new Set(); + if (!toolUseId) { + return { assistantUuids, outputMessageUuids }; + } + + const messagesByUuid = new Map(args.parsedMessages.map((message) => [message.uuid, message])); + const messageIndexByUuid = new Map( + args.parsedMessages.map((message, index) => [message.uuid, index]) + ); + + const addMatchingAssistant = (uuid: string | null | undefined): void => { + if (!uuid) { + return; + } + const message = messagesByUuid.get(uuid); + if (message && messageHasToolUse(message, toolUseId)) { + assistantUuids.add(message.uuid); + } + }; + + const addNearestPreviousMatchingAssistant = (message: ParsedMessage): void => { + const startIndex = messageIndexByUuid.get(message.uuid); + if (startIndex === undefined) { + return; + } + + for (let index = startIndex - 1; index >= 0; index -= 1) { + const candidate = args.parsedMessages[index]; + if (!candidate) { + continue; + } + if (candidate.type !== 'assistant') { + continue; + } + if (messageHasToolUse(candidate, toolUseId)) { + assistantUuids.add(candidate.uuid); + } + return; + } + }; + + addMatchingAssistant(args.candidate.anchor.messageUuid); + for (const explicitMessageId of args.explicitMessageIds) { + const message = messagesByUuid.get(explicitMessageId); + if (!message) { + continue; + } + addMatchingAssistant(message.uuid); + addMatchingAssistant(message.sourceToolAssistantUUID); + addMatchingAssistant(message.parentUuid); + if (message.type === 'user' && messageHasToolResult(message, toolUseId)) { + addNearestPreviousMatchingAssistant(message); + } + } + + let previousAssistantUuid: string | undefined; + for (const message of args.parsedMessages) { + const referencesTool = + message.sourceToolUseID === toolUseId || messageHasToolResult(message, toolUseId); + if ( + referencesTool && + ((message.sourceToolAssistantUUID !== undefined && + assistantUuids.has(message.sourceToolAssistantUUID)) || + (message.parentUuid !== null && + message.parentUuid !== undefined && + assistantUuids.has(message.parentUuid)) || + (message.sourceToolAssistantUUID === undefined && + (message.parentUuid === null || message.parentUuid === undefined) && + previousAssistantUuid !== undefined && + assistantUuids.has(previousAssistantUuid))) + ) { + outputMessageUuids.add(message.uuid); + } + + if (message.type === 'assistant') { + previousAssistantUuid = message.uuid; + } + } + + return { toolUseId, assistantUuids, outputMessageUuids }; +} + +function isToolLinkedMessage(message: ParsedMessage, scope: ToolAnchorScope): boolean { + const { toolUseId } = scope; + if (!toolUseId) { + return false; + } + + const hasScopedAssistant = scope.assistantUuids.size > 0; + if (scope.outputMessageUuids.has(message.uuid)) { + return true; + } + + if (message.type === 'assistant' && messageHasToolUse(message, toolUseId)) { + return !hasScopedAssistant || scope.assistantUuids.has(message.uuid); + } + + const referencesTool = + message.sourceToolUseID === toolUseId || messageHasToolResult(message, toolUseId); + if (!referencesTool) { + return false; + } + + if (!hasScopedAssistant) { + return true; + } + + return ( + (message.sourceToolAssistantUUID !== undefined && + scope.assistantUuids.has(message.sourceToolAssistantUUID)) || + (message.parentUuid !== null && + message.parentUuid !== undefined && + scope.assistantUuids.has(message.parentUuid)) + ); } function noteExactDiagnostic( @@ -120,16 +259,18 @@ function filterMessageForCandidate(args: { message: ParsedMessage; candidate: BoardTaskExactLogBundleCandidate; explicitMessageIds: Set; + toolAnchorScope: ToolAnchorScope; }): TentativeFilteredMessage | null { - const { message, candidate, explicitMessageIds } = args; + const { message, candidate, explicitMessageIds, toolAnchorScope } = args; const explicitMessageLinked = explicitMessageIds.has(message.uuid); const toolUseId = candidate.anchor.kind === 'tool' ? candidate.anchor.toolUseId : undefined; - const anchoredOutputLinked = isToolAnchoredOutputMessage(message, toolUseId); + const toolLinked = isToolLinkedMessage(message, toolAnchorScope); + + if (!explicitMessageLinked && !toolLinked) { + return null; + } if (typeof message.content === 'string') { - if (!explicitMessageLinked && !anchoredOutputLinked) { - return null; - } return { original: message, filteredContent: message.content, @@ -142,7 +283,7 @@ function filterMessageForCandidate(args: { filteredBlocks = filterAssistantContent( message.content, toolUseId, - explicitMessageLinked || anchoredOutputLinked + explicitMessageLinked || toolLinked ); } else if (message.type === 'user') { filteredBlocks = filterUserArrayContent(message.content, toolUseId, explicitMessageLinked); @@ -309,6 +450,11 @@ export class BoardTaskExactLogDetailSelector { } const explicitMessageIds = new Set(relevantRecords.map((record) => record.source.messageUuid)); + const toolAnchorScope = buildToolAnchorScope({ + candidate, + parsedMessages, + explicitMessageIds, + }); const tentative: TentativeFilteredMessage[] = []; for (const message of parsedMessages) { @@ -316,6 +462,7 @@ export class BoardTaskExactLogDetailSelector { message, candidate, explicitMessageIds, + toolAnchorScope, }); if (filtered) { tentative.push(filtered); diff --git a/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts b/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts index fd822753..d3799d2b 100644 --- a/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts +++ b/src/main/services/team/taskLogs/exact/BoardTaskExactLogStrictParser.ts @@ -5,6 +5,8 @@ import { createReadStream } from 'fs'; import * as fs from 'fs/promises'; import * as readline from 'readline'; +import { TranscriptSessionActorContextTracker } from '../TranscriptSessionActorContext'; + import { BoardTaskExactLogsParseCache } from './BoardTaskExactLogsParseCache'; import type { ParsedMessage } from '@main/types'; @@ -40,7 +42,7 @@ async function mapLimit( if (currentIndex >= items.length) { return; } - results[currentIndex] = await fn(items[currentIndex]!); + results[currentIndex] = await fn(items[currentIndex]); } }); await Promise.all(workers); @@ -106,6 +108,7 @@ export class BoardTaskExactLogStrictParser { private async readStrictFile(filePath: string): Promise { const results: ParsedMessage[] = []; + const actorContextTracker = new TranscriptSessionActorContextTracker(); const stream = createReadStream(filePath, { encoding: 'utf8' }); const rl = readline.createInterface({ input: stream, @@ -124,7 +127,9 @@ export class BoardTaskExactLogStrictParser { continue; } - const parsed = parseJsonlEntry(record as unknown as ChatHistoryEntry); + actorContextTracker.remember(record); + const contextRecord = actorContextTracker.apply(record); + const parsed = parseJsonlEntry(contextRecord as unknown as ChatHistoryEntry); if (parsed) { results.push(parsed); } diff --git a/src/main/services/team/taskLogs/exact/fileVersions.ts b/src/main/services/team/taskLogs/exact/fileVersions.ts index 720b64ed..100c916d 100644 --- a/src/main/services/team/taskLogs/exact/fileVersions.ts +++ b/src/main/services/team/taskLogs/exact/fileVersions.ts @@ -19,7 +19,7 @@ async function mapLimit( if (currentIndex >= items.length) { return; } - results[currentIndex] = await fn(items[currentIndex]!); + results[currentIndex] = await fn(items[currentIndex]); } }); await Promise.all(workers); diff --git a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts index 0a035df8..ef20647f 100644 --- a/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts +++ b/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts @@ -4,6 +4,8 @@ import { createLogger } from '@shared/utils/logger'; import { getTaskDisplayId } from '@shared/utils/taskIdentity'; import { canonicalizeAgentTeamsToolName } from '../../agentTeamsToolNames'; +import { TeamConfigReader } from '../../TeamConfigReader'; +import { TeamMembersMetaStore } from '../../TeamMembersMetaStore'; import { TeamTaskReader } from '../../TeamTaskReader'; import { BoardTaskActivityRecordSource } from '../activity/BoardTaskActivityRecordSource'; import { TeamTranscriptSourceLocator } from '../discovery/TeamTranscriptSourceLocator'; @@ -18,6 +20,7 @@ import { OpenCodeTaskLogStreamSource } from './OpenCodeTaskLogStreamSource'; import type { BoardTaskActivityRecord } from '../activity/BoardTaskActivityRecord'; import type { BoardTaskExactLogDetailCandidate } from '../exact/BoardTaskExactLogTypes'; +import type { TaskLogRuntimeStreamSource } from './TaskLogRuntimeStreamSource'; import type { ContentBlock, ParsedMessage, ToolUseResultData } from '@main/types'; import type { BoardTaskActivityCategory, @@ -57,6 +60,7 @@ interface TimeWindow { interface StreamLayout { participants: BoardTaskLogParticipant[]; visibleSlices: StreamSlice[]; + shouldMergeRuntimeFallback?: boolean; } const logger = createLogger('Service:BoardTaskLogStreamService'); @@ -87,6 +91,7 @@ const HISTORICAL_BOARD_ACTION_TOOL_NAMES = new Set([ 'task_set_owner', 'task_unlink', ]); +const READ_ONLY_BOARD_TOOL_NAMES = new Set(['task_get', 'task_get_comment']); const TASK_REFERENCE_KEYS = new Set(['task', 'taskid', 'id', 'displayid', 'targetid']); function emptyResponse(): BoardTaskLogStreamResponse { @@ -266,6 +271,24 @@ function inferHistoricalActionCategory(canonicalToolName: string): BoardTaskActi } } +function historicalBoardToolReferencesTask(args: { + canonicalToolName: string; + input: Record; + resultPayload: unknown; + taskRefs: Set; +}): boolean { + const { canonicalToolName, input, resultPayload, taskRefs } = args; + if (valueReferencesTask(input, taskRefs)) { + return true; + } + + if (canonicalToolName === 'task_get' || canonicalToolName === 'task_get_comment') { + return false; + } + + return valueReferencesTask(resultPayload, taskRefs); +} + function asObjectRecord(value: unknown): Record | null { return value && typeof value === 'object' && !Array.isArray(value) ? (value as Record) @@ -516,6 +539,74 @@ function parseJsonLikeString(value: string): unknown { } } +function formatTaskStatusPayload(payload: Record): string | null { + const displayId = + typeof payload.displayId === 'string' && payload.displayId.trim().length > 0 + ? payload.displayId.trim() + : typeof payload.id === 'string' && payload.id.trim().length > 0 + ? payload.id.trim() + : null; + const status = + typeof payload.status === 'string' && payload.status.trim().length > 0 + ? payload.status.trim() + : null; + if (!displayId || !status) { + return null; + } + return `Task ${displayId} ${status}`; +} + +function formatMessageSendPayload(payload: Record): string | null { + const routing = payload.routing as Record | undefined; + const messageRecord = + typeof payload.message === 'object' && payload.message !== null + ? (payload.message as Record) + : undefined; + const deliveryMessage = + typeof payload.message === 'string' && payload.message.trim().length > 0 + ? payload.message.trim() + : null; + const summary = + typeof messageRecord?.summary === 'string' && messageRecord.summary.trim().length > 0 + ? messageRecord.summary.trim() + : typeof routing?.summary === 'string' && routing.summary.trim().length > 0 + ? routing.summary.trim() + : null; + const target = + typeof messageRecord?.to === 'string' && messageRecord.to.trim().length > 0 + ? messageRecord.to.trim() + : typeof routing?.target === 'string' && routing.target.trim().length > 0 + ? routing.target.trim() + : null; + const messageText = + typeof messageRecord?.text === 'string' && messageRecord.text.trim().length > 0 + ? messageRecord.text.trim() + : null; + + if (deliveryMessage && summary) { + return `${deliveryMessage} - ${summary}`; + } + if (summary && target) { + return `Message sent to ${target} - ${summary}`; + } + if (summary) { + return summary; + } + if (deliveryMessage) { + return deliveryMessage; + } + if (messageText && target) { + return `Message sent to ${target} - ${messageText}`; + } + if (messageText) { + return messageText; + } + if (target) { + return `Message sent to ${target}`; + } + return null; +} + function extractBoardToolOutputText( toolName: string | undefined, parsedPayload: unknown @@ -524,8 +615,8 @@ function extractBoardToolOutputText( return null; } - const normalizedToolName = toolName.trim().toLowerCase(); - const payload = parsedPayload as Record; + const normalizedToolName = canonicalizeBoardToolName(toolName) ?? toolName.trim().toLowerCase(); + const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record); if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') { const comment = payload.comment as Record | undefined; if (typeof comment?.text === 'string' && comment.text.trim().length > 0) { @@ -533,41 +624,41 @@ function extractBoardToolOutputText( } } - if (normalizedToolName === 'sendmessage') { - const routing = payload.routing as Record | undefined; - const deliveryMessage = - typeof payload.message === 'string' && payload.message.trim().length > 0 - ? payload.message.trim() - : null; - const summary = - typeof routing?.summary === 'string' && routing.summary.trim().length > 0 - ? routing.summary.trim() - : null; - const target = - typeof routing?.target === 'string' && routing.target.trim().length > 0 - ? routing.target.trim() - : null; + if (normalizedToolName === 'task_complete') { + return formatTaskStatusPayload(payload) ?? 'Task completed'; + } - if (deliveryMessage && summary) { - return `${deliveryMessage} - ${summary}`; - } - if (summary && target) { - return `Message sent to ${target} - ${summary}`; - } - if (summary) { - return summary; - } - if (deliveryMessage) { - return deliveryMessage; - } - if (target) { - return `Message sent to ${target}`; - } + if (normalizedToolName === 'sendmessage' || normalizedToolName === 'message_send') { + return formatMessageSendPayload(payload); + } + + if (payload.message) { + return formatMessageSendPayload(payload); + } + + if (payload.status) { + return formatTaskStatusPayload(payload); } return null; } +function unwrapAgentTeamsResponsePayload( + payload: Record +): Record { + const wrapperKey = Object.keys(payload).find( + (key) => key.startsWith('agent_teams_') && key.endsWith('_response') + ); + if (!wrapperKey) { + return payload; + } + + const nested = payload[wrapperKey]; + return typeof nested === 'object' && nested !== null && !Array.isArray(nested) + ? (nested as Record) + : payload; +} + function collectTextBlockText(value: unknown): string { if (!Array.isArray(value)) { return ''; @@ -637,9 +728,10 @@ function sanitizeToolResultContent( const parsedPayload = parseJsonLikeString(content.content); const extractedText = extractBoardToolOutputText(canonicalToolName, parsedPayload); if (typeof extractedText === 'string') { + const { is_error: _isError, ...rest } = content; return { - ...content, - content: [{ type: 'text', text: extractedText }], + ...rest, + content: extractedText, }; } return parsedPayload ? { ...content, content: '' } : cloneBlock(content); @@ -728,6 +820,10 @@ function sanitizeToolResultPayloadValue( return sanitizedChildren.length > 0 ? sanitizedChildren : ''; } +function hasExtractedBoardToolOutput(value: string | unknown[]): boolean { + return typeof value === 'string' && value.trim().length > 0 && !looksLikeJsonPayload(value); +} + function sanitizeJsonLikeToolResultPayloads( messages: ParsedMessage[], canonicalToolName?: string @@ -742,6 +838,7 @@ function sanitizeJsonLikeToolResultPayloads( return { ...toolResult, content: nextContent, + isError: hasExtractedBoardToolOutput(nextContent) ? false : toolResult.isError, }; } return toolResult; @@ -1278,7 +1375,10 @@ function collectExplicitToolUseIds( function collectAllowedMemberNames( task: TeamTask, - records: { actor: { memberName?: string } }[] + records: { + actor: { memberName?: string }; + action?: { category?: BoardTaskActivityCategory; canonicalToolName?: string }; + }[] ): Set { const allowedNames = new Set(); @@ -1287,6 +1387,14 @@ function collectAllowedMemberNames( } for (const record of records) { + const canonicalToolName = canonicalizeBoardToolName(record.action?.canonicalToolName); + if ( + record.action?.category === 'read' || + (canonicalToolName !== null && READ_ONLY_BOARD_TOOL_NAMES.has(canonicalToolName)) + ) { + continue; + } + if (typeof record.actor.memberName === 'string' && record.actor.memberName.trim().length > 0) { allowedNames.add(normalizeMemberName(record.actor.memberName)); } @@ -1421,6 +1529,64 @@ function countSegmentsFromSlices(visibleSlices: StreamSlice[]): number { return segmentCount; } +function mergeParticipants( + primary: BoardTaskLogParticipant[], + fallback: BoardTaskLogParticipant[] +): BoardTaskLogParticipant[] { + const participantsByKey = new Map(); + for (const participant of [...primary, ...fallback]) { + if (!participantsByKey.has(participant.key)) { + participantsByKey.set(participant.key, participant); + } + } + + return Array.from(participantsByKey.values()).sort((left, right) => { + if (left.isLead && !right.isLead) return 1; + if (!left.isLead && right.isLead) return -1; + return 0; + }); +} + +function mergeSegments( + primary: BoardTaskLogSegment[], + fallback: BoardTaskLogSegment[] +): BoardTaskLogSegment[] { + const segmentsById = new Map(); + for (const segment of [...primary, ...fallback]) { + if (!segmentsById.has(segment.id)) { + segmentsById.set(segment.id, segment); + } + } + + return Array.from(segmentsById.values()).sort((left, right) => { + const leftTs = Date.parse(left.startTimestamp); + const rightTs = Date.parse(right.startTimestamp); + if (Number.isFinite(leftTs) && Number.isFinite(rightTs) && leftTs !== rightTs) { + return leftTs - rightTs; + } + return left.id.localeCompare(right.id); + }); +} + +function chooseDefaultFilter(participants: BoardTaskLogParticipant[]): 'all' | string { + const namedParticipants = participants.filter((participant) => !participant.isLead); + return namedParticipants.length === 1 ? namedParticipants[0].key : 'all'; +} + +function mergeRuntimeFallbackResponse( + primary: BoardTaskLogStreamResponse, + fallback: BoardTaskLogStreamResponse +): BoardTaskLogStreamResponse { + const participants = mergeParticipants(primary.participants, fallback.participants); + return { + participants, + defaultFilter: chooseDefaultFilter(participants), + segments: mergeSegments(primary.segments, fallback.segments), + source: primary.source, + runtimeProjection: fallback.runtimeProjection ?? primary.runtimeProjection, + }; +} + export class BoardTaskLogStreamService { private readonly layoutCache = new Map< string, @@ -1440,7 +1606,9 @@ export class BoardTaskLogStreamService { private readonly chunkBuilder: BoardTaskExactLogChunkBuilder = new BoardTaskExactLogChunkBuilder(), private readonly taskReader: TeamTaskReader = new TeamTaskReader(), private readonly transcriptSourceLocator: TeamTranscriptSourceLocator = new TeamTranscriptSourceLocator(), - private readonly runtimeFallbackSource: OpenCodeTaskLogStreamSource = new OpenCodeTaskLogStreamSource() + private readonly runtimeFallbackSource: TaskLogRuntimeStreamSource = new OpenCodeTaskLogStreamSource(), + private readonly membersMetaStore: TeamMembersMetaStore = new TeamMembersMetaStore(), + private readonly configReader: TeamConfigReader = new TeamConfigReader() ) {} private buildLayoutCacheKey(teamName: string, taskId: string): string { @@ -1707,8 +1875,12 @@ export class BoardTaskLogStreamService { const resultPayload = resolveToolResultPayload(message, toolResult); if ( - !valueReferencesTask(toolCall.input, taskRefs) && - !valueReferencesTask(resultPayload, taskRefs) + !historicalBoardToolReferencesTask({ + canonicalToolName: toolCall.canonicalToolName, + input: toolCall.input, + resultPayload, + taskRefs, + }) ) { continue; } @@ -1898,9 +2070,59 @@ export class BoardTaskLogStreamService { return { participants: buildOrderedParticipants(visibleSlices), visibleSlices, + shouldMergeRuntimeFallback: await this.shouldMergeRuntimeFallback(teamName, taskId, records), }; } + private async shouldMergeRuntimeFallback( + teamName: string, + taskId: string, + records: BoardTaskActivityRecord[] + ): Promise { + if (records.some((record) => record.linkKind === 'execution')) { + return false; + } + + try { + const [activeTasks, deletedTasks, metaMembers, config] = await Promise.all([ + this.taskReader.getTasks(teamName).catch(() => []), + this.taskReader.getDeletedTasks(teamName).catch(() => []), + this.membersMetaStore.getMembers(teamName).catch(() => []), + this.configReader.getConfig(teamName).catch(() => null), + ]); + const task = [...activeTasks, ...deletedTasks].find((candidate) => candidate.id === taskId); + const ownerName = task?.owner?.trim(); + if (!ownerName) { + return false; + } + + const normalizedOwner = normalizeMemberName(ownerName); + const member = [...metaMembers, ...(config?.members ?? [])].find( + (candidate) => normalizeMemberName(candidate.name) === normalizedOwner + ); + return member?.providerId === 'opencode'; + } catch { + return false; + } + } + + private async loadRuntimeFallback( + teamName: string, + taskId: string + ): Promise { + const startedAt = Date.now(); + const fallback = await this.runtimeFallbackSource.getTaskLogStream(teamName, taskId); + const elapsedMs = Date.now() - startedAt; + if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) { + logger.warn( + `Slow task-log runtime fallback: team=${teamName} task=${taskId} hit=${Boolean( + fallback + )} elapsedMs=${elapsedMs}` + ); + } + return fallback; + } + async getTaskLogStreamSummary( teamName: string, taskId: string @@ -1926,16 +2148,7 @@ export class BoardTaskLogStreamService { const layout = await this.getStreamLayout(teamName, taskId); if (layout.visibleSlices.length === 0) { - const startedAt = Date.now(); - const fallback = await this.runtimeFallbackSource.getTaskLogStream(teamName, taskId); - const elapsedMs = Date.now() - startedAt; - if (elapsedMs >= RUNTIME_FALLBACK_WARN_MS) { - logger.warn( - `Slow OpenCode task-log runtime fallback: team=${teamName} task=${taskId} hit=${Boolean( - fallback - )} elapsedMs=${elapsedMs}` - ); - } + const fallback = await this.loadRuntimeFallback(teamName, taskId); return fallback ?? emptyResponse(); } @@ -1984,14 +2197,18 @@ export class BoardTaskLogStreamService { } flushSegment(); - const namedParticipants = layout.participants.filter((participant) => !participant.isLead); - const defaultFilter = namedParticipants.length === 1 ? namedParticipants[0].key : 'all'; - - return { + const primaryResponse: BoardTaskLogStreamResponse = { participants: layout.participants, - defaultFilter, + defaultFilter: chooseDefaultFilter(layout.participants), segments, source: 'transcript', }; + + if (!layout.shouldMergeRuntimeFallback) { + return primaryResponse; + } + + const fallback = await this.loadRuntimeFallback(teamName, taskId); + return fallback ? mergeRuntimeFallbackResponse(primaryResponse, fallback) : primaryResponse; } } diff --git a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts index da34b3c5..bea26f9b 100644 --- a/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts +++ b/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts @@ -1,3 +1,4 @@ +import { sanitizeDisplayContent } from '@shared/utils/contentSanitizer'; import { createLogger } from '@shared/utils/logger'; import { ClaudeMultimodelBridgeService } from '../../../runtime/ClaudeMultimodelBridgeService'; @@ -35,6 +36,15 @@ const WINDOW_GRACE_AFTER_MS = 15_000; const ATTRIBUTION_WINDOW_GRACE_MS = 1_000; const TASK_MARKER_CONTEXT_BEFORE_MESSAGES = 1; const TASK_MARKER_CONTEXT_MAX_MS = 5 * 60_000; +const NATIVE_TOOL_CONTEXT_BEFORE_MS = 5 * 60_000; +const NATIVE_TOOL_CONTEXT_AFTER_MS = 5 * 60_000; + +const AGENT_TEAMS_TOOL_PREFIXES = [ + 'mcp__agent-teams__', + 'mcp__agent_teams__', + 'agent-teams_', + 'agent_teams_', +] as const; const TASK_LOG_MARKER_TOOL_NAMES = new Set([ 'task_start', @@ -51,6 +61,22 @@ const TASK_LOG_MARKER_TOOL_NAMES = new Set([ 'review_request_changes', ]); +const BOARD_MCP_TOOL_NAMES = new Set([ + ...TASK_LOG_MARKER_TOOL_NAMES, + 'runtime_bootstrap_checkin', + 'member_briefing', + 'message_send', + 'cross_team_send', + 'task_create', + 'task_create_from_message', + 'task_get', + 'task_get_comment', + 'task_list', + 'task_update', + 'task_delete', + 'process_list', +]); + const TERMINAL_TASK_MARKER_TOOL_NAMES = new Set([ 'task_complete', 'review_approve', @@ -103,6 +129,13 @@ interface TaskMarkerProjection { messages: ParsedMessage[]; markerMatchCount: number; markerSpanCount: number; + boardMcpToolCount: number; + nativeToolCount: number; +} + +interface ProjectionToolCounts { + boardMcpToolCount: number; + nativeToolCount: number; } type HeuristicFallbackReason = @@ -283,6 +316,44 @@ function refsIntersect(left: Set, right: Set): boolean { return false; } +function isBoardMcpToolName(rawName: string): boolean { + const normalizedRawName = rawName + .trim() + .replace(/^proxy_/, '') + .toLowerCase(); + const canonicalName = canonicalizeAgentTeamsToolName(rawName).trim().toLowerCase(); + return ( + AGENT_TEAMS_TOOL_PREFIXES.some((prefix) => normalizedRawName.startsWith(prefix)) || + BOARD_MCP_TOOL_NAMES.has(canonicalName) + ); +} + +function isNativeOpenCodeToolName(rawName: string): boolean { + const normalizedName = rawName.trim(); + return normalizedName.length > 0 && !isBoardMcpToolName(normalizedName); +} + +function messageHasNativeOpenCodeToolCall(message: ParsedMessage): boolean { + return message.toolCalls.some((toolCall) => isNativeOpenCodeToolName(toolCall.name ?? '')); +} + +function countProjectionToolCalls(messages: ParsedMessage[]): ProjectionToolCounts { + let boardMcpToolCount = 0; + let nativeToolCount = 0; + + for (const message of messages) { + for (const toolCall of message.toolCalls) { + if (isNativeOpenCodeToolName(toolCall.name ?? '')) { + nativeToolCount += 1; + } else if (isBoardMcpToolName(toolCall.name ?? '')) { + boardMcpToolCount += 1; + } + } + } + + return { boardMcpToolCount, nativeToolCount }; +} + function markerInputReferencesTaskInTeam( input: unknown, teamName: string, @@ -503,11 +574,16 @@ function resolveMarkerSpanStart(messages: ParsedMessage[], markerIndex: number): function findLastMessageIndexInWindow( messages: ParsedMessage[], startIndex: number, - window: TimeWindow + window: TimeWindow, + maxEndMs = Number.POSITIVE_INFINITY ): number { let endIndex = startIndex; for (let index = startIndex + 1; index < messages.length; index += 1) { - if (!isWithinSingleTimeWindow(messages[index].timestamp, window)) { + const message = messages[index]; + if (!message || message.timestamp.getTime() > maxEndMs) { + break; + } + if (!isWithinSingleTimeWindow(message.timestamp, window)) { break; } endIndex = index; @@ -561,7 +637,11 @@ function buildMarkerSpan( lastMarker.windowIndex === null ? undefined : (windows[lastMarker.windowIndex] ?? undefined); if (!isTerminalTaskMarkerMatch(lastMarker) && window) { - endIndex = findLastMessageIndexInWindow(messages, lastMarker.index, window); + const maxEndMs = + window.endMs === null + ? messages[lastMarker.index].timestamp.getTime() + TASK_MARKER_CONTEXT_MAX_MS + : Number.POSITIVE_INFINITY; + endIndex = findLastMessageIndexInWindow(messages, lastMarker.index, window, maxEndMs); } return { @@ -570,6 +650,107 @@ function buildMarkerSpan( }; } +function clampWindowToTaskWindow( + window: TimeWindow, + taskWindow: TimeWindow | undefined +): TimeWindow { + if (!taskWindow) { + return window; + } + + const taskEndMs = taskWindow.endMs ?? Date.now(); + return { + startMs: Math.max(window.startMs, taskWindow.startMs), + endMs: Math.min(window.endMs ?? taskEndMs, taskEndMs), + }; +} + +function buildNativeToolWindowForMarkerGroup( + messages: ParsedMessage[], + markerGroup: TaskMarkerMatch[], + span: { startIndex: number; endIndex: number }, + taskWindows: TimeWindow[] +): TimeWindow | null { + const firstMarker = markerGroup[0]; + const lastMarker = markerGroup[markerGroup.length - 1]; + if (!firstMarker || !lastMarker) { + return null; + } + + const groupHasStartMarker = markerGroup.some((match) => + match.markerCalls.some((markerCall) => markerCall.toolName === 'task_start') + ); + const spanStartMessage = messages[span.startIndex]; + const lastMarkerMessage = messages[lastMarker.index]; + if (!spanStartMessage || !lastMarkerMessage) { + return null; + } + + const startMs = groupHasStartMarker + ? spanStartMessage.timestamp.getTime() + : lastMarkerMessage.timestamp.getTime() - NATIVE_TOOL_CONTEXT_BEFORE_MS; + const taskWindow = + lastMarker.windowIndex === null + ? undefined + : (taskWindows[lastMarker.windowIndex] ?? undefined); + const endMs = isTerminalTaskMarkerMatch(lastMarker) + ? Math.max( + messages[span.endIndex]?.timestamp.getTime() ?? lastMarkerMessage.timestamp.getTime(), + lastMarkerMessage.timestamp.getTime() + ) + : (taskWindow?.endMs ?? lastMarkerMessage.timestamp.getTime() + NATIVE_TOOL_CONTEXT_AFTER_MS); + const clamped = clampWindowToTaskWindow({ startMs, endMs }, taskWindow); + return clamped.startMs <= (clamped.endMs ?? Date.now()) ? clamped : null; +} + +function addNativeToolIndexesInWindows( + includedIndexes: Set, + messages: ParsedMessage[], + windows: TimeWindow[] +): void { + if (windows.length === 0) { + return; + } + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if (!messageHasNativeOpenCodeToolCall(message)) { + continue; + } + if (isWithinTimeWindows(message.timestamp, windows)) { + includedIndexes.add(index); + } + } +} + +function addToolResultIndexesForIncludedAssistants( + includedIndexes: Set, + messages: ParsedMessage[] +): void { + const includedAssistantUuids = new Set(); + for (const index of includedIndexes) { + const message = messages[index]; + if (message?.type === 'assistant') { + includedAssistantUuids.add(message.uuid); + } + } + + if (includedAssistantUuids.size === 0) { + return; + } + + for (let index = 0; index < messages.length; index += 1) { + const message = messages[index]; + if ( + message?.isMeta && + message.sourceToolAssistantUUID && + includedAssistantUuids.has(message.sourceToolAssistantUUID) + ) { + includedIndexes.add(index); + } + } +} + function buildTaskMarkerProjection( projectedMessages: OpenCodeRuntimeTranscriptLogMessage[], teamName: string, @@ -594,15 +775,36 @@ function buildTaskMarkerProjection( return null; } - const spans = groupMarkerMatches(markerMatches, taskWindows) - .map((group) => buildMarkerSpan(parsedMessages, group, taskWindows)) - .filter((span): span is { startIndex: number; endIndex: number } => span !== null); + const markerGroups = groupMarkerMatches(markerMatches, taskWindows); + const spansWithGroups = markerGroups + .map((group) => { + const span = buildMarkerSpan(parsedMessages, group, taskWindows); + return span ? { group, span } : null; + }) + .filter( + ( + item + ): item is { group: TaskMarkerMatch[]; span: { startIndex: number; endIndex: number } } => + item !== null + ); const includedIndexes = new Set(); - for (const span of spans) { + const nativeToolWindows: TimeWindow[] = []; + for (const { group, span } of spansWithGroups) { for (let index = span.startIndex; index <= span.endIndex; index += 1) { includedIndexes.add(index); } + const nativeToolWindow = buildNativeToolWindowForMarkerGroup( + parsedMessages, + group, + span, + taskWindows + ); + if (nativeToolWindow) { + nativeToolWindows.push(nativeToolWindow); + } } + addNativeToolIndexesInWindows(includedIndexes, parsedMessages, nativeToolWindows); + addToolResultIndexesForIncludedAssistants(includedIndexes, parsedMessages); const messages = [...includedIndexes] .sort((left, right) => left - right) @@ -617,7 +819,8 @@ function buildTaskMarkerProjection( ? { messages, markerMatchCount, - markerSpanCount: spans.length, + markerSpanCount: spansWithGroups.length, + ...countProjectionToolCalls(messages), } : null; } @@ -737,8 +940,10 @@ function mapOpenCodeContentBlock( block: OpenCodeRuntimeTranscriptLogContentBlock ): ContentBlock | null { switch (block.type) { - case 'text': - return { type: 'text', text: block.text }; + case 'text': { + const text = sanitizeDisplayContent(block.text); + return text.length > 0 ? { type: 'text', text } : null; + } case 'thinking': return { type: 'thinking', @@ -795,7 +1000,7 @@ function toParsedMessage(message: OpenCodeRuntimeTranscriptLogMessage): ParsedMe const normalizedContent: ContentBlock[] | string = typeof message.content === 'string' - ? message.content + ? sanitizeDisplayContent(message.content) : message.content .map(mapOpenCodeContentBlock) .filter((item): item is ContentBlock => item !== null); @@ -986,6 +1191,12 @@ export class OpenCodeTaskLogStreamSource { .filter((message): message is ParsedMessage => message !== null) .filter((message) => isWithinTimeWindows(message.timestamp, timeWindows)) .sort((left, right) => left.timestamp.getTime() - right.timestamp.getTime()); + const toolCounts = markerProjection + ? { + boardMcpToolCount: markerProjection.boardMcpToolCount, + nativeToolCount: markerProjection.nativeToolCount, + } + : countProjectionToolCalls(filteredMessages); if (filteredMessages.length === 0) { return null; @@ -1014,7 +1225,7 @@ export class OpenCodeTaskLogStreamSource { }; logger.debug( - `[${teamName}/${task.id}] using OpenCode runtime fallback for task log stream (${filteredMessages.length} messages, owner=${ownerName})` + `[${teamName}/${task.id}] using OpenCode runtime fallback for task log stream (${filteredMessages.length} messages, owner=${ownerName}, boardMcpTools=${toolCounts.boardMcpToolCount}, nativeTools=${toolCounts.nativeToolCount})` ); return { @@ -1027,6 +1238,7 @@ export class OpenCodeTaskLogStreamSource { mode: 'heuristic', attributionRecordCount: projectionContext.attributionRecordCount, projectedMessageCount: filteredMessages.length, + ...toolCounts, fallbackReason: projectionReason, ...(markerProjection ? { @@ -1119,6 +1331,8 @@ export class OpenCodeTaskLogStreamSource { const participants: BoardTaskLogParticipant[] = []; const segments: BoardTaskLogSegment[] = []; let projectedMessageCount = 0; + let boardMcpToolCount = 0; + let nativeToolCount = 0; for (const member of members.sort((left, right) => { const leftStart = left.messages[0]?.timestamp.getTime() ?? 0; const rightStart = right.messages[0]?.timestamp.getTime() ?? 0; @@ -1139,7 +1353,10 @@ export class OpenCodeTaskLogStreamSource { } const participant = buildParticipant(member.memberName); + const memberToolCounts = countProjectionToolCalls(member.messages); projectedMessageCount += member.messages.length; + boardMcpToolCount += memberToolCounts.boardMcpToolCount; + nativeToolCount += memberToolCounts.nativeToolCount; participants.push(participant); segments.push({ id: `opencode-attributed:${teamName}:${task.id}:${normalizeMemberName(member.memberName)}`, @@ -1156,7 +1373,7 @@ export class OpenCodeTaskLogStreamSource { } logger.debug( - `[${teamName}/${task.id}] using OpenCode task-log attribution (${segments.length} segment(s), ${attributionRecords.length} record(s))` + `[${teamName}/${task.id}] using OpenCode task-log attribution (${segments.length} segment(s), ${attributionRecords.length} record(s), boardMcpTools=${boardMcpToolCount}, nativeTools=${nativeToolCount})` ); return { @@ -1169,6 +1386,8 @@ export class OpenCodeTaskLogStreamSource { mode: 'attribution', attributionRecordCount: attributionRecords.length, projectedMessageCount, + boardMcpToolCount, + nativeToolCount, }, }; } diff --git a/src/main/services/team/taskLogs/stream/TaskLogRuntimeStreamSource.ts b/src/main/services/team/taskLogs/stream/TaskLogRuntimeStreamSource.ts new file mode 100644 index 00000000..abf8cf85 --- /dev/null +++ b/src/main/services/team/taskLogs/stream/TaskLogRuntimeStreamSource.ts @@ -0,0 +1,5 @@ +import type { BoardTaskLogStreamResponse } from '@shared/types'; + +export interface TaskLogRuntimeStreamSource { + getTaskLogStream(teamName: string, taskId: string): Promise; +} diff --git a/src/preload/index.ts b/src/preload/index.ts index 50d07719..47fbbe23 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -118,8 +118,8 @@ import { TEAM_CANCEL_PROVISIONING, TEAM_CHANGE, TEAM_CREATE, - TEAM_CREATE_INITIAL_GIT_COMMIT, TEAM_CREATE_CONFIG, + TEAM_CREATE_INITIAL_GIT_COMMIT, TEAM_CREATE_TASK, TEAM_DELETE_DRAFT, TEAM_DELETE_TASK_ATTACHMENT, diff --git a/src/renderer/api/httpClient.ts b/src/renderer/api/httpClient.ts index d231be2e..451149dd 100644 --- a/src/renderer/api/httpClient.ts +++ b/src/renderer/api/httpClient.ts @@ -67,12 +67,12 @@ import type { TeamProvisioningModelVerificationMode, TeamProvisioningPrepareResult, TeamProvisioningProgress, - TeamWorktreeGitStatus, TeamsAPI, TeamSummary, TeamTask, TeamTaskStatus, TeamViewSnapshot, + TeamWorktreeGitStatus, TmuxAPI, TmuxStatus, TriggerTestResult, diff --git a/src/renderer/assets/participant-avatars/01.png b/src/renderer/assets/participant-avatars/01.png index 4128d3b0..555980fc 100644 Binary files a/src/renderer/assets/participant-avatars/01.png and b/src/renderer/assets/participant-avatars/01.png differ diff --git a/src/renderer/assets/participant-avatars/02.png b/src/renderer/assets/participant-avatars/02.png index 15575859..868dacec 100644 Binary files a/src/renderer/assets/participant-avatars/02.png and b/src/renderer/assets/participant-avatars/02.png differ diff --git a/src/renderer/assets/participant-avatars/03.png b/src/renderer/assets/participant-avatars/03.png index a5e00bcd..d2e053ca 100644 Binary files a/src/renderer/assets/participant-avatars/03.png and b/src/renderer/assets/participant-avatars/03.png differ diff --git a/src/renderer/assets/participant-avatars/04.png b/src/renderer/assets/participant-avatars/04.png index f984db69..a9269b1f 100644 Binary files a/src/renderer/assets/participant-avatars/04.png and b/src/renderer/assets/participant-avatars/04.png differ diff --git a/src/renderer/assets/participant-avatars/05.png b/src/renderer/assets/participant-avatars/05.png index a9795962..b3008d0e 100644 Binary files a/src/renderer/assets/participant-avatars/05.png and b/src/renderer/assets/participant-avatars/05.png differ diff --git a/src/renderer/assets/participant-avatars/06.png b/src/renderer/assets/participant-avatars/06.png index 71950d32..10bdde45 100644 Binary files a/src/renderer/assets/participant-avatars/06.png and b/src/renderer/assets/participant-avatars/06.png differ diff --git a/src/renderer/assets/participant-avatars/07.png b/src/renderer/assets/participant-avatars/07.png index 8f23fb86..71092d70 100644 Binary files a/src/renderer/assets/participant-avatars/07.png and b/src/renderer/assets/participant-avatars/07.png differ diff --git a/src/renderer/assets/participant-avatars/08.png b/src/renderer/assets/participant-avatars/08.png index c7ada81e..ee2d3b23 100644 Binary files a/src/renderer/assets/participant-avatars/08.png and b/src/renderer/assets/participant-avatars/08.png differ diff --git a/src/renderer/assets/participant-avatars/09.png b/src/renderer/assets/participant-avatars/09.png index 8f4abe98..ceebd09a 100644 Binary files a/src/renderer/assets/participant-avatars/09.png and b/src/renderer/assets/participant-avatars/09.png differ diff --git a/src/renderer/assets/participant-avatars/10.png b/src/renderer/assets/participant-avatars/10.png index bee2490e..388d005f 100644 Binary files a/src/renderer/assets/participant-avatars/10.png and b/src/renderer/assets/participant-avatars/10.png differ diff --git a/src/renderer/assets/participant-avatars/11.png b/src/renderer/assets/participant-avatars/11.png index e77da7e4..2dbf8426 100644 Binary files a/src/renderer/assets/participant-avatars/11.png and b/src/renderer/assets/participant-avatars/11.png differ diff --git a/src/renderer/assets/participant-avatars/12.png b/src/renderer/assets/participant-avatars/12.png index 32ee4912..1706574f 100644 Binary files a/src/renderer/assets/participant-avatars/12.png and b/src/renderer/assets/participant-avatars/12.png differ diff --git a/src/renderer/assets/participant-avatars/13.png b/src/renderer/assets/participant-avatars/13.png index 9b774e24..3d26dee8 100644 Binary files a/src/renderer/assets/participant-avatars/13.png and b/src/renderer/assets/participant-avatars/13.png differ diff --git a/src/renderer/components/chat/items/LinkedToolItem.tsx b/src/renderer/components/chat/items/LinkedToolItem.tsx index e9707e40..336ce076 100644 --- a/src/renderer/components/chat/items/LinkedToolItem.tsx +++ b/src/renderer/components/chat/items/LinkedToolItem.tsx @@ -80,6 +80,7 @@ export const LinkedToolItem: React.FC = ({ const status = getToolStatus(linkedTool); const { isLight } = useTheme(); const summary = getToolSummary(linkedTool.name, linkedTool.input); + const normalizedToolName = linkedTool.name.toLowerCase(); const summaryNode = searchQueryOverride && searchQueryOverride.trim().length > 0 ? highlightQueryInText( @@ -159,16 +160,16 @@ export const LinkedToolItem: React.FC = ({ // Determine which specialized viewer to use const useReadViewer = - linkedTool.name === 'Read' && hasReadContent(linkedTool) && !linkedTool.result?.isError; - const useEditViewer = linkedTool.name === 'Edit' && hasEditContent(linkedTool); + normalizedToolName === 'read' && hasReadContent(linkedTool) && !linkedTool.result?.isError; + const useEditViewer = normalizedToolName === 'edit' && hasEditContent(linkedTool); const useWriteViewer = - linkedTool.name === 'Write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError; + normalizedToolName === 'write' && hasWriteContent(linkedTool) && !linkedTool.result?.isError; const useSkillViewer = linkedTool.name === 'Skill' && hasSkillInstructions(linkedTool); const useDefaultViewer = !useReadViewer && !useEditViewer && !useWriteViewer && !useSkillViewer; // Check if we should show error display for Read/Write tools - const showReadError = linkedTool.name === 'Read' && linkedTool.result?.isError; - const showWriteError = linkedTool.name === 'Write' && linkedTool.result?.isError; + const showReadError = normalizedToolName === 'read' && linkedTool.result?.isError; + const showWriteError = normalizedToolName === 'write' && linkedTool.result?.isError; return (
diff --git a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx index 38f8ec70..fc9ae282 100644 --- a/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/DefaultToolViewer.tsx @@ -9,7 +9,12 @@ import React from 'react'; import { type ItemStatus } from '../BaseItem'; import { CollapsibleOutputSection } from './CollapsibleOutputSection'; -import { extractOutputText, renderInput, renderOutput } from './renderHelpers'; +import { + extractOutputText, + formatToolOutputForDisplay, + renderInput, + renderOutput, +} from './renderHelpers'; import type { LinkedToolItem } from '@renderer/types/groups'; @@ -19,10 +24,13 @@ interface DefaultToolViewerProps { } export const DefaultToolViewer: React.FC = ({ linkedTool, status }) => { + const displayOutputContent = linkedTool.result + ? formatToolOutputForDisplay(linkedTool.name, linkedTool.result.content) + : null; const hasMeaningfulOutput = - linkedTool.result && + displayOutputContent !== null && (() => { - const text = extractOutputText(linkedTool.result.content).trim(); + const text = extractOutputText(displayOutputContent).trim(); return text.length > 0 && text !== '[]' && text !== '{}'; })(); @@ -46,11 +54,14 @@ export const DefaultToolViewer: React.FC = ({ linkedTool
{/* Output Section β€” Collapsed by default */} - {!linkedTool.isOrphaned && linkedTool.result && hasMeaningfulOutput && ( - - {renderOutput(linkedTool.result.content)} - - )} + {!linkedTool.isOrphaned && + linkedTool.result && + hasMeaningfulOutput && + displayOutputContent && ( + + {renderOutput(displayOutputContent)} + + )} ); }; diff --git a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx index 14fba8aa..c2931cff 100644 --- a/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx +++ b/src/renderer/components/chat/items/linkedTool/WriteToolViewer.tsx @@ -17,11 +17,22 @@ interface WriteToolViewerProps { export const WriteToolViewer: React.FC = ({ linkedTool }) => { const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; - const filePath = (toolUseResult?.filePath as string) || (linkedTool.input.file_path as string); - const content = (toolUseResult?.content as string) || (linkedTool.input.content as string) || ''; + const filePath = + (toolUseResult?.filePath as string) || + (linkedTool.input.file_path as string) || + (linkedTool.input.filePath as string) || + (linkedTool.input.path as string) || + 'write-output'; + const content = + (toolUseResult?.content as string) || + (linkedTool.input.content as string) || + (linkedTool.input.text as string) || + ''; const isCreate = toolUseResult?.type === 'create'; const isMarkdownFile = /\.mdx?$/i.test(filePath); - const [viewMode, setViewMode] = React.useState<'code' | 'preview'>(isMarkdownFile ? 'preview' : 'code'); + const [viewMode, setViewMode] = React.useState<'code' | 'preview'>( + isMarkdownFile ? 'preview' : 'code' + ); return (
diff --git a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx index 13320b3f..924e5725 100644 --- a/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx +++ b/src/renderer/components/chat/items/linkedTool/renderHelpers.tsx @@ -19,11 +19,12 @@ import { getAgentToolDisplayDetails } from '@shared/utils/toolSummary'; * Renders the input section based on tool type with theme-aware styling. */ export function renderInput(toolName: string, input: Record): React.ReactElement { + const normalizedToolName = toolName.toLowerCase(); // Special rendering for Edit tool - show diff-like format - if (toolName === 'Edit') { - const filePath = input.file_path as string | undefined; - const oldString = input.old_string as string | undefined; - const newString = input.new_string as string | undefined; + if (normalizedToolName === 'edit') { + const filePath = readInputString(input, ['file_path', 'filePath', 'path']); + const oldString = readInputString(input, ['old_string', 'oldString']); + const newString = readInputString(input, ['new_string', 'newString']); const replaceAll = input.replace_all as boolean | undefined; return ( @@ -57,9 +58,9 @@ export function renderInput(toolName: string, input: Record): R } // Special rendering for Bash tool - if (toolName === 'Bash') { - const command = input.command as string | undefined; - const description = input.description as string | undefined; + if (normalizedToolName === 'bash') { + const command = readInputString(input, ['command']); + const description = readInputString(input, ['description']); const highlighted = command ? highlightLines(command, 'command.sh') : null; return ( @@ -81,8 +82,8 @@ export function renderInput(toolName: string, input: Record): R } // Special rendering for Read tool - if (toolName === 'Read') { - const filePath = input.file_path as string | undefined; + if (normalizedToolName === 'read') { + const filePath = readInputString(input, ['file_path', 'filePath', 'path']); const offset = input.offset as number | undefined; const limit = input.limit as number | undefined; @@ -168,18 +169,34 @@ export function renderInput(toolName: string, input: Record): R // Default: key-value format with readable string values return (
- {Object.entries(input).map(([key, value]) => ( -
-
- {key} + {Object.entries(input).length > 0 ? ( + Object.entries(input).map(([key, value]) => ( +
+
+ {key} +
+
{formatInputValue(value)}
-
{formatInputValue(value)}
+ )) + ) : ( +
+ No input recorded for this tool call.
- ))} + )}
); } +function readInputString(input: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = input[key]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return undefined; +} + function formatInputValue(value: unknown): string { if (typeof value === 'string') { return value; @@ -240,6 +257,150 @@ export function extractOutputText(content: string | unknown[]): string { return displayText; } +export function formatToolOutputForDisplay( + toolName: string, + content: string | unknown[] +): string | unknown[] { + if (!isAgentTeamsToolName(toolName)) { + return content; + } + + const parsed = parseJsonObject(extractOutputText(content)); + if (!parsed) { + return content; + } + + const unwrapped = unwrapAgentTeamsResponse(parsed); + if (!unwrapped) { + return content; + } + + const lines = formatAgentTeamsResponse(toolName, unwrapped.wrapperKey, unwrapped.payload); + return lines.length > 0 ? lines.join('\n') : content; +} + +function isAgentTeamsToolName(toolName: string): boolean { + return ( + toolName.startsWith('agent-teams_') || + toolName.startsWith('agent_teams_') || + toolName.startsWith('mcp__agent-teams__') || + toolName.startsWith('mcp__agent_teams__') + ); +} + +function parseJsonObject(text: string): Record | null { + try { + const parsed: unknown = JSON.parse(text); + return asRecord(parsed); + } catch { + return null; + } +} + +function unwrapAgentTeamsResponse( + parsed: Record +): { wrapperKey: string | null; payload: Record } | null { + const wrapperKey = + Object.keys(parsed).find( + (key) => key.startsWith('agent_teams_') && key.endsWith('_response') + ) ?? null; + const payload = wrapperKey ? asRecord(parsed[wrapperKey]) : parsed; + return payload ? { wrapperKey, payload } : null; +} + +function formatAgentTeamsResponse( + toolName: string, + wrapperKey: string | null, + payload: Record +): string[] { + if (hasErrorPayload(payload)) { + return []; + } + + const lines: string[] = [getAgentTeamsResponseTitle(toolName, wrapperKey)]; + appendField(lines, 'Team', readString(payload.teamName)); + appendField(lines, 'Task ID', readString(payload.taskId)); + appendField(lines, 'Message ID', readString(payload.messageId)); + + const comment = asRecord(payload.comment); + if (comment) { + appendField(lines, 'Comment ID', readString(comment.id)); + appendField(lines, 'Author', readString(comment.author)); + appendField(lines, 'Created', readString(comment.createdAt)); + appendBody(lines, readString(comment.text)); + return lines; + } + + const message = asRecord(payload.message); + if (message) { + appendField(lines, 'From', readString(message.from)); + appendField(lines, 'To', readString(message.to)); + appendField(lines, 'Created', readString(message.createdAt)); + appendBody(lines, readString(message.text) ?? readString(message.summary)); + return lines; + } + + const task = asRecord(payload.task); + if (task) { + appendField(lines, 'Task', readString(task.title) ?? readString(task.name)); + appendField(lines, 'Status', readString(task.status)); + appendField(lines, 'Owner', readString(task.owner)); + appendBody(lines, readString(task.description)); + return lines; + } + + appendField(lines, 'Status', readString(payload.status)); + appendBody(lines, readString(payload.text) ?? readString(payload.summary)); + return lines.length > 1 ? lines : []; +} + +function hasErrorPayload(payload: Record): boolean { + return ( + typeof payload.error === 'string' || + typeof payload.errorMessage === 'string' || + payload.ok === false || + payload.success === false + ); +} + +function getAgentTeamsResponseTitle(toolName: string, wrapperKey: string | null): string { + const key = `${toolName} ${wrapperKey ?? ''}`; + if (key.includes('task_add_comment')) return 'Task comment added'; + if (key.includes('task_complete')) return 'Task completed'; + if (key.includes('task_start')) return 'Task started'; + if (key.includes('task_set_owner')) return 'Task owner updated'; + if (key.includes('task_set_clarification')) return 'Task clarification updated'; + if (key.includes('task_attach_comment_file')) return 'Task comment file attached'; + if (key.includes('message_send')) return 'Message sent'; + if (key.includes('task_get')) return 'Task loaded'; + return 'Agent Teams tool result'; +} + +function appendField(lines: string[], label: string, value: string | null | undefined): void { + if (!value || value.trim().length === 0) { + return; + } + lines.push(`${label}: ${value.trim()}`); +} + +function appendBody(lines: string[], value: string | null | undefined): void { + if (!value || value.trim().length === 0) { + return; + } + lines.push(''); + lines.push(value.trim()); +} + +function readString(value: unknown): string | null { + return typeof value === 'string' ? value : null; +} + +function asRecord(value: unknown): Record | null { + return typeof value === 'object' && value !== null && !Array.isArray(value) + ? (value as Record) + : null; +} + function isContentBlock(value: unknown): boolean { return ( typeof value === 'object' && diff --git a/src/renderer/components/chat/viewers/MarkdownViewer.tsx b/src/renderer/components/chat/viewers/MarkdownViewer.tsx index a6ec0fe2..8c2a7082 100644 --- a/src/renderer/components/chat/viewers/MarkdownViewer.tsx +++ b/src/renderer/components/chat/viewers/MarkdownViewer.tsx @@ -1083,7 +1083,7 @@ export const MarkdownViewer: React.FC = ({ {/* Markdown content with scroll */}
-
+
({ key: s.key, label: s.label, })); +const PROVIDER_API_KEY_FLAG_PATTERN = + /(--(?:openai|codex|anthropic)[-_]api[-_]key(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; +const SECRET_FLAG_PATTERN = + /(--(?:api[-_]key|token|password|secret|authorization|auth[-_]token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi; +const SECRET_ENV_ASSIGNMENT_PATTERN = + /\b([A-Z0-9_]*(?:API_KEY|TOKEN|SECRET|PASSWORD|AUTHORIZATION)[A-Z0-9_]*\s*=\s*)("[^"]*"|'[^']*'|\S+)/gi; +const AUTH_HEADER_PATTERN = /\b(Authorization\s*:\s*)(Bearer\s+)?("[^"]*"|'[^']*'|\S+)/gi; export interface ProvisioningProgressBlockProps { /** Title above the steps, e.g. "Launching team" */ @@ -138,6 +147,86 @@ function sanitizeAssistantOutput(raw?: string, isError = false): string | null { ); } +function redactProvisioningDiagnosticsCopy(text: string): string { + return text + .replace(PROVIDER_API_KEY_FLAG_PATTERN, '$1[redacted]') + .replace(SECRET_FLAG_PATTERN, '$1[redacted]') + .replace(SECRET_ENV_ASSIGNMENT_PATTERN, '$1[redacted]') + .replace(AUTH_HEADER_PATTERN, '$1$2[redacted]'); +} + +function formatOptionalValue(value: string | number | null | undefined): string { + if (value === null || value === undefined || value === '') { + return '(none)'; + } + return String(value); +} + +function formatLaunchDiagnosticsCopy( + items: readonly TeamLaunchDiagnosticItem[] | undefined +): string { + if (!items || items.length === 0) { + return '(none)'; + } + + return items + .map((item) => + [ + `- id: ${item.id}`, + item.memberName ? ` member: ${item.memberName}` : undefined, + ` severity: ${item.severity}`, + ` code: ${item.code}`, + ` label: ${item.label}`, + item.detail ? ` detail: ${item.detail}` : undefined, + ` observedAt: ${item.observedAt}`, + ] + .filter((line): line is string => Boolean(line)) + .join('\n') + ) + .join('\n'); +} + +function buildProvisioningDiagnosticsCopy(input: { + title: string; + message?: string | null; + messageSeverity?: 'error' | 'warning' | 'info'; + tone: 'default' | 'error'; + startedAt?: string; + elapsed?: string | null; + pid?: number; + currentStepIndex: number; + errorStepIndex?: number; + liveOutput?: string | null; + cliLogsTail?: string; + launchDiagnostics?: TeamLaunchDiagnosticItem[]; +}): string { + const payload = [ + '# Team provisioning diagnostics', + '', + '## Summary', + `Title: ${input.title}`, + `Message: ${formatOptionalValue(input.message)}`, + `Message severity: ${formatOptionalValue(input.messageSeverity)}`, + `Tone: ${input.tone}`, + `Started at: ${formatOptionalValue(input.startedAt)}`, + `Elapsed: ${formatOptionalValue(input.elapsed)}`, + `PID: ${formatOptionalValue(input.pid)}`, + `Current step index: ${input.currentStepIndex}`, + `Error step index: ${formatOptionalValue(input.errorStepIndex)}`, + '', + '## Launch diagnostics', + formatLaunchDiagnosticsCopy(input.launchDiagnostics), + '', + '## Live output', + input.liveOutput?.trim() || '(empty)', + '', + '## CLI logs tail', + input.cliLogsTail?.trim() || '(empty)', + ].join('\n'); + + return redactProvisioningDiagnosticsCopy(payload).trim(); +} + export const ProvisioningProgressBlock = ({ title, message, @@ -164,9 +253,42 @@ export const ProvisioningProgressBlock = ({ const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false); const [diagnosticsOpen, setDiagnosticsOpen] = useState(false); const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen); + const [diagnosticsCopied, setDiagnosticsCopied] = useState(false); const outputScrollRef = useRef(null); + const copyResetTimerRef = useRef(null); const isError = tone === 'error'; const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError); + const diagnosticsCopyText = useMemo( + () => + buildProvisioningDiagnosticsCopy({ + title, + message, + messageSeverity, + tone, + startedAt, + elapsed, + pid, + currentStepIndex, + errorStepIndex, + liveOutput: displayAssistantOutput, + cliLogsTail, + launchDiagnostics, + }), + [ + title, + message, + messageSeverity, + tone, + startedAt, + elapsed, + pid, + currentStepIndex, + errorStepIndex, + displayAssistantOutput, + cliLogsTail, + launchDiagnostics, + ] + ); const visibleLaunchDiagnostics = launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ?? []; @@ -198,6 +320,36 @@ export const ProvisioningProgressBlock = ({ } }, [isError, cliLogsTail]); + useEffect( + () => () => { + if (copyResetTimerRef.current !== null) { + window.clearTimeout(copyResetTimerRef.current); + } + }, + [] + ); + + const copyDiagnostics = async (): Promise => { + if (!navigator.clipboard?.writeText) { + setDiagnosticsCopied(false); + return; + } + try { + await navigator.clipboard.writeText(diagnosticsCopyText); + } catch { + setDiagnosticsCopied(false); + return; + } + setDiagnosticsCopied(true); + if (copyResetTimerRef.current !== null) { + window.clearTimeout(copyResetTimerRef.current); + } + copyResetTimerRef.current = window.setTimeout(() => { + copyResetTimerRef.current = null; + setDiagnosticsCopied(false); + }, 1500); + }; + return (
) : null}
- +
+ + +
{liveOutputOpen ? (
, - leadActivityByTeam: Record -): TeamStatus { - if (aliveTeams.includes(teamName)) { - return leadActivityByTeam[teamName] === 'active' ? 'active' : 'idle'; - } - if ( - currentProgress && - ['validating', 'spawning', 'configuring', 'assembling', 'finalizing', 'verifying'].includes( - currentProgress.state - ) - ) { - return 'provisioning'; - } - if (team.teamLaunchState === 'partial_pending') { - return 'partial_pending'; - } - if (team.teamLaunchState === 'partial_skipped') { - return 'partial_skipped'; - } - if (team.partialLaunchFailure || team.teamLaunchState === 'partial_failure') { - return 'partial_failure'; - } - return 'offline'; -} - const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => { switch (status) { case 'active': @@ -362,38 +325,68 @@ export const TeamListView = (): React.JSX.Element => { return synthetic.length > 0 ? [...teams, ...synthetic] : teams; }, [teams, provisioningTeamNames, provisioningSnapshotByTeam]); - // Fetch alive teams on mount and when teams list changes + const fetchAliveTeams = useCallback(async (): Promise => { + if (!electronMode) return null; + try { + return await api.teams.aliveList(); + } catch { + return null; + } + }, [electronMode]); + + // Fetch alive teams on mount and when teams list changes. useEffect(() => { - if (!electronMode) return; let cancelled = false; - const fetchAlive = async (): Promise => { - try { - const list = await api.teams.aliveList(); - if (!cancelled) setAliveTeams(list); - } catch { - // best-effort + void fetchAliveTeams().then((list) => { + if (!cancelled && list) { + setAliveTeams(list); } - }; - void fetchAlive(); + }); return () => { cancelled = true; }; - }, [electronMode, teams]); + }, [fetchAliveTeams, teams]); + + const readyProgressRefreshKey = useMemo(() => { + return Object.entries(currentProvisioningRunIdByTeam) + .map(([teamName, runId]) => { + if (!runId) return null; + const progress = provisioningRuns[runId]; + return progress?.state === 'ready' + ? `${teamName}:${progress.runId}:${progress.updatedAt}` + : null; + }) + .filter((item): item is string => Boolean(item)) + .join('|'); + }, [currentProvisioningRunIdByTeam, provisioningRuns]); + + // Terminal launch progress can arrive before aliveList catches up. + useEffect(() => { + if (!readyProgressRefreshKey) return; + let cancelled = false; + void fetchAliveTeams().then((list) => { + if (!cancelled && list) { + setAliveTeams(list); + } + }); + return () => { + cancelled = true; + }; + }, [fetchAliveTeams, readyProgressRefreshKey]); // Refresh alive teams when opening the create dialog so conflict warning is accurate. useEffect(() => { if (!electronMode || !showCreateDialog) return; let cancelled = false; - void api.teams - .aliveList() - .then((list) => { - if (!cancelled) setAliveTeams(list); - }) - .catch(() => undefined); + void fetchAliveTeams().then((list) => { + if (!cancelled && list) { + setAliveTeams(list); + } + }); return () => { cancelled = true; }; - }, [electronMode, showCreateDialog]); + }, [electronMode, fetchAliveTeams, showCreateDialog]); const currentProjectSelection = useMemo( () => @@ -438,24 +431,32 @@ export const TeamListView = (): React.JSX.Element => { getCurrentProvisioningProgressForTeam(provisioningState, t.teamName), leadActivityByTeam ); - const isRunning = - status !== 'offline' && status !== 'partial_failure' && status !== 'partial_pending'; + const isRunning = isTeamListStatusRunning(status); if (filter.selectedStatuses.has('running') && isRunning) return true; if (filter.selectedStatuses.has('offline') && !isRunning) return true; return false; }); } - const aliveSet = new Set(aliveTeams); const matchesCurrentProject = currentProjectPath ? (team: TeamSummary): boolean => teamMatchesProjectSelection(team, currentProjectPath) : null; + const nowMs = Date.now(); + const statusForTeam = (team: TeamSummary): TeamStatus => + resolveTeamStatus( + team, + team.teamName, + aliveTeams, + getCurrentProvisioningProgressForTeam(provisioningState, team.teamName), + leadActivityByTeam, + nowMs + ); result = [...result].sort((a, b) => { - // 1. Alive (running) teams first - const aliveA = aliveSet.has(a.teamName) ? 0 : 1; - const aliveB = aliveSet.has(b.teamName) ? 0 : 1; - if (aliveA !== aliveB) return aliveA - aliveB; + // 1. Running teams first, including the short ready-before-alive-list gap. + const runningA = isTeamListStatusRunning(statusForTeam(a)) ? 0 : 1; + const runningB = isTeamListStatusRunning(statusForTeam(b)) ? 0 : 1; + if (runningA !== runningB) return runningA - runningB; // 2. Teams related to the selected project are prioritized next if (matchesCurrentProject) { diff --git a/src/renderer/components/team/activity/ActivityItem.tsx b/src/renderer/components/team/activity/ActivityItem.tsx index 0ad6473d..5a14ac1e 100644 --- a/src/renderer/components/team/activity/ActivityItem.tsx +++ b/src/renderer/components/team/activity/ActivityItem.tsx @@ -1161,8 +1161,8 @@ export const ActivityItem = memo( tabIndex={isHeaderClickable ? 0 : undefined} className={[ useCompactCollapsedHeader - ? 'min-w-0 px-3 py-2' - : 'flex min-w-0 items-center gap-2 px-3 py-2', + ? 'min-w-0 px-2.5 py-1.5' + : 'flex min-w-0 items-center gap-2 px-2.5 py-1.5', isHeaderClickable ? 'cursor-pointer select-none' : '', ].join(' ')} onClick={handleHeaderToggle} @@ -1396,7 +1396,7 @@ export const ActivityItem = memo( {/* Content β€” collapsed for system messages, expanded for others */} {isExpanded ? ( -
+
{structured ? (
{autoSummary && autoSummary !== messageType ? ( @@ -1547,6 +1547,7 @@ export const ActivityItem = memo(
diff --git a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx index c45884df..dfebc8c5 100644 --- a/src/renderer/components/team/dialogs/CreateTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/CreateTeamDialog.tsx @@ -560,7 +560,7 @@ export const CreateTeamDialog = ({ effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree'); const worktreeGitReadiness = useWorktreeGitReadiness( effectiveCwd || null, - open && canCreate && !soloTeam + open && canCreate && hasSelectedWorktreeIsolation ); const worktreeIsolationDisabledReason = !soloTeam && canCreate ? getWorktreeGitControlDisabledReason(worktreeGitReadiness) : null; @@ -1840,7 +1840,7 @@ export const CreateTeamDialog = ({

) : null} - {!soloTeam && canCreate ? ( + {canCreate && hasSelectedWorktreeIsolation ? ( ) : null}
diff --git a/src/renderer/components/team/dialogs/EditTeamDialog.tsx b/src/renderer/components/team/dialogs/EditTeamDialog.tsx index 828b7982..47c01efc 100644 --- a/src/renderer/components/team/dialogs/EditTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/EditTeamDialog.tsx @@ -23,6 +23,7 @@ import { getTeamColorSet, getThemedBadge } from '@renderer/constants/teamColors' import { useFileListCacheWarmer } from '@renderer/hooks/useFileListCacheWarmer'; import { useTheme } from '@renderer/hooks/useTheme'; import { cn } from '@renderer/lib/utils'; +import { isGeminiUiFrozen } from '@renderer/utils/geminiUiFreeze'; import { agentAvatarUrl, buildMemberColorMap, @@ -579,6 +580,7 @@ export const EditTeamDialog = ({ disableAddMember={isTeamAlive} addMemberLockReason="Use the dedicated Add member dialog to add new teammates while the team is live." memberWarningById={memberWarningById} + disableGeminiOption={isGeminiUiFrozen()} />
{isTeamProvisioning ? ( diff --git a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx index aec4b90b..73490e06 100644 --- a/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx +++ b/src/renderer/components/team/dialogs/LaunchTeamDialog.tsx @@ -306,6 +306,26 @@ function deriveTeammateWorktreeDefault( ); } +function buildWorktreePathByMemberName( + members: readonly { + name: string; + isolation?: 'worktree'; + cwd?: string; + removedAt?: number | string | null; + }[] +): Record { + const paths: Record = {}; + for (const member of members) { + const name = member.name.trim().toLowerCase(); + const cwd = member.cwd?.trim(); + if (!name || member.removedAt || member.isolation !== 'worktree' || !cwd) { + continue; + } + paths[name] = cwd; + } + return paths; +} + // ============================================================================= // Component // ============================================================================= @@ -458,6 +478,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const [maxTurns, setMaxTurns] = useState(50); const [maxBudgetUsd, setMaxBudgetUsd] = useState(''); const [scheduleHydrationKey, setScheduleHydrationKey] = useState(null); + const [worktreePathByMemberName, setWorktreePathByMemberName] = useState>( + {} + ); const effectiveMemberDrafts = useMemo( () => (syncModelsWithLead ? membersDrafts.map(clearMemberModelOverrides) : membersDrafts), [membersDrafts, syncModelsWithLead] @@ -802,6 +825,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen normalizeMemberDraftForProviderMode(member, multimodelEnabled) ) ); + setWorktreePathByMemberName(buildWorktreePathByMemberName(editableMembersSource)); setTeammateWorktreeDefault(deriveTeammateWorktreeDefault(editableMembersSource)); setSyncModelsWithLead( !editableMembersSource.some((member) => member.providerId || member.model || member.effort) @@ -1280,6 +1304,31 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen return warnings; }, [memberRuntimeWarningById, teammateRuntimeCompatibility.memberWarningById]); + const memberWorktreeContinuationInfoById = useMemo(() => { + if (!isLaunchMode) { + return {}; + } + + const info: Record = {}; + for (const member of effectiveMemberDrafts) { + if (member.removedAt || member.isolation !== 'worktree') { + continue; + } + const lookupName = (member.originalName?.trim() || member.name.trim()).toLowerCase(); + if (!lookupName) { + continue; + } + const previousWorktreePath = worktreePathByMemberName[lookupName]; + if (!previousWorktreePath) { + continue; + } + info[member.id] = + `This teammate will continue from its existing worktree: ${previousWorktreePath}`; + } + + return info; + }, [effectiveMemberDrafts, isLaunchMode, worktreePathByMemberName]); + // --------------------------------------------------------------------------- // Launch-only effects // --------------------------------------------------------------------------- @@ -1291,7 +1340,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen const hasSelectedWorktreeIsolation = isLaunchMode && effectiveMemberDrafts.some((member) => !member.removedAt && member.isolation === 'worktree'); - const worktreeGitReadiness = useWorktreeGitReadiness(effectiveCwd || null, open && isLaunchMode); + const worktreeGitReadiness = useWorktreeGitReadiness( + effectiveCwd || null, + open && hasSelectedWorktreeIsolation + ); const worktreeIsolationDisabledReason = isLaunchMode ? getWorktreeGitControlDisabledReason(worktreeGitReadiness) : null; @@ -2451,11 +2503,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault} leadWarningText={leadRuntimeWarningText} memberWarningById={combinedMemberRuntimeWarningById} + memberInfoById={memberWorktreeContinuationInfoById} leadModelIssueText={leadModelIssueText} memberModelIssueById={memberModelIssueById} softDeleteMembers disableGeminiOption={isGeminiUiFrozen()} - headerBottom={} + headerBottom={ + hasSelectedWorktreeIsolation ? ( + + ) : null + } />
diff --git a/src/renderer/components/team/dialogs/SendMessageDialog.tsx b/src/renderer/components/team/dialogs/SendMessageDialog.tsx index 7e94716f..f241e23d 100644 --- a/src/renderer/components/team/dialogs/SendMessageDialog.tsx +++ b/src/renderer/components/team/dialogs/SendMessageDialog.tsx @@ -27,7 +27,6 @@ import { buildReplyBlock } from '@renderer/utils/agentMessageFormatting'; import { removeChipTokenFromText } from '@renderer/utils/chipUtils'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import { extractTaskRefsFromText, stripEncodedTaskReferenceMetadata, @@ -41,6 +40,7 @@ import { MemberBadge } from '../MemberBadge'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { InlineChip } from '@renderer/types/inlineChip'; import type { MentionSuggestion } from '@renderer/types/mention'; +import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import type { AttachmentPayload, ResolvedTeamMember, diff --git a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx index a73d8b7e..9ecda341 100644 --- a/src/renderer/components/team/dialogs/TaskDetailDialog.tsx +++ b/src/renderer/components/team/dialogs/TaskDetailDialog.tsx @@ -55,7 +55,6 @@ import { import { linkifyTaskIdsInMarkdown, parseTaskLinkHref } from '@renderer/utils/taskReferenceUtils'; import { isLeadMember } from '@shared/utils/leadDetection'; import { getTaskKanbanColumn } from '@shared/utils/reviewState'; -import { canDisplayTaskChanges } from '@shared/utils/taskChangeState'; import { deriveTaskDisplayId, formatTaskDisplayLabel, @@ -86,6 +85,7 @@ import { } from 'lucide-react'; const TASK_CHANGES_AUTO_REFRESH_MS = 20_000; +const TASK_CHANGES_INITIAL_LOAD_DELAY_MS = 1_500; import { SourceMessageAttachments } from '../attachments/SourceMessageAttachments'; @@ -325,8 +325,9 @@ export const TaskDetailDialog = ({ ? currentTask.sourceMessage.attachments.length : 0; - // Lazy-load task changes for any displayable state (in_progress, review, approved, completed). - const canShowTaskChanges = currentTask ? canDisplayTaskChanges(currentTask) : false; + // Changes is the explicit lazy-load entry point. Keep it visible for all team tasks, + // including old/pending tasks that may resolve to an empty result. + const canShowTaskChanges = Boolean(currentTask); const taskSince = useMemo(() => deriveTaskSince(currentTask), [currentTask]); const taskChangeRequestOptions = useMemo( () => (currentTask ? buildTaskChangeRequestOptions(currentTask) : null), @@ -361,13 +362,7 @@ export const TaskDetailDialog = ({ const loadTaskChangeSummary = useCallback( async (forceFresh = false): Promise => { - if ( - !currentTask || - !taskChangeSummaryOptions || - variant !== 'team' || - !canShowTaskChanges || - !onViewChanges - ) { + if (!currentTask || !taskChangeSummaryOptions || variant !== 'team' || !canShowTaskChanges) { return null; } const data = await api.review.getTaskChanges(teamName, currentTask.id, { @@ -376,7 +371,7 @@ export const TaskDetailDialog = ({ }); return data; }, - [canShowTaskChanges, currentTask, onViewChanges, taskChangeSummaryOptions, teamName, variant] + [canShowTaskChanges, currentTask, taskChangeSummaryOptions, teamName, variant] ); const syncTaskChangeSummaryResult = useCallback( @@ -410,14 +405,7 @@ export const TaskDetailDialog = ({ preserveFilesOnError?: boolean; } = {}): Promise => { const requestKey = currentTaskChangeSummaryKeyRef.current; - if ( - !requestKey || - !currentTask || - variant !== 'team' || - !canShowTaskChanges || - !onViewChanges - ) - return; + if (!requestKey || !currentTask || variant !== 'team' || !canShowTaskChanges) return; if (taskChangesLoadInFlightKeysRef.current.has(requestKey)) return; taskChangesLoadInFlightKeysRef.current.add(requestKey); @@ -449,32 +437,27 @@ export const TaskDetailDialog = ({ } } }, - [ - canShowTaskChanges, - currentTask, - loadTaskChangeSummary, - onViewChanges, - syncTaskChangeSummaryResult, - variant, - ] + [canShowTaskChanges, currentTask, loadTaskChangeSummary, syncTaskChangeSummaryResult, variant] ); useEffect(() => { if (variant !== 'team') return; - if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen) - return; + if (!open || !currentTask || !canShowTaskChanges || !changesSectionOpen) return; const summaryKey = currentTaskChangeSummaryKey; if (loadedTaskChangeSummaryKeyRef.current === summaryKey) { return; } + if (taskChangesFiles !== null) { + loadedTaskChangeSummaryKeyRef.current = summaryKey; + return; + } loadedTaskChangeSummaryKeyRef.current = summaryKey; - // Show full loading state only when no files are cached yet; - // otherwise let the refresh button spinner indicate background reload. + // The manual open path only reaches this branch when no summary is cached yet. void requestTaskChangeSummary({ forceFresh: false, - showSpinner: !taskChangesFiles || taskChangesFiles.length === 0, + showSpinner: true, preserveFilesOnError: false, }); }, [ @@ -483,7 +466,6 @@ export const TaskDetailDialog = ({ currentTask, canShowTaskChanges, teamName, - onViewChanges, currentTaskChangeSummaryKey, taskChangeRequestSignature, variant, @@ -491,6 +473,41 @@ export const TaskDetailDialog = ({ taskChangesFiles, ]); + useEffect(() => { + if (variant !== 'team') return; + if (!open || !currentTask || !canShowTaskChanges || changesSectionOpen) return; + if (!currentTaskChangeSummaryKey || taskChangesFiles !== null) return; + + const summaryKey = currentTaskChangeSummaryKey; + if (loadedTaskChangeSummaryKeyRef.current === summaryKey) { + return; + } + + const timer = window.setTimeout(() => { + if (currentTaskChangeSummaryKeyRef.current !== summaryKey) { + return; + } + void requestTaskChangeSummary({ + forceFresh: false, + showSpinner: true, + preserveFilesOnError: true, + }); + }, TASK_CHANGES_INITIAL_LOAD_DELAY_MS); + + return () => { + window.clearTimeout(timer); + }; + }, [ + changesSectionOpen, + open, + currentTask, + canShowTaskChanges, + currentTaskChangeSummaryKey, + requestTaskChangeSummary, + taskChangesFiles, + variant, + ]); + useEffect(() => { if (!open || !changesSectionOpen) { loadedTaskChangeSummaryKeyRef.current = null; @@ -499,7 +516,7 @@ export const TaskDetailDialog = ({ useEffect(() => { if (variant !== 'team') return; - if (!open || !currentTask || !canShowTaskChanges || !onViewChanges || !changesSectionOpen) { + if (!open || !currentTask || !canShowTaskChanges || !changesSectionOpen) { return; } @@ -519,7 +536,6 @@ export const TaskDetailDialog = ({ open, currentTask, canShowTaskChanges, - onViewChanges, requestTaskChangeSummary, variant, ]); @@ -1138,14 +1154,21 @@ export const TaskDetailDialog = ({ {/* Changes */} - {variant === 'team' && canShowTaskChanges && onViewChanges ? ( + {variant === 'team' && canShowTaskChanges ? ( } - badge={taskChangesFiles ? taskChangesFiles.length : undefined} + badge={ + !taskChangesLoading && taskChangesFiles ? taskChangesFiles.length : undefined + } headerExtra={ - changesSectionOpen ? ( + taskChangesLoading && !changesSectionOpen ? ( + + ) : changesSectionOpen ? ( + {onViewChanges ? ( + + ) : ( + + {file.relativePath} + + )} {file.linesAdded > 0 ? ( +{file.linesAdded} @@ -1211,21 +1240,23 @@ export const TaskDetailDialog = ({ ) : null} - - - - - Review diff - + {onViewChanges ? ( + + + + + Review diff + + ) : null} {onOpenInEditor ? ( diff --git a/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx b/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx index b9dc9559..d86f7b03 100644 --- a/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx +++ b/src/renderer/components/team/dialogs/WorktreeGitReadinessBanner.tsx @@ -147,13 +147,13 @@ export function getWorktreeGitControlDisabledReason( return state.status.canUseWorktrees ? null : (state.status.message ?? null); } -export function WorktreeGitReadinessBanner({ +export const WorktreeGitReadinessBanner = ({ state, showReady = false, }: { state: WorktreeGitReadinessState; showReady?: boolean; -}): React.JSX.Element | null { +}): React.JSX.Element | null => { const { status, loading, actionLoading, error, initializeRepository, createInitialCommit } = state; @@ -240,4 +240,4 @@ export function WorktreeGitReadinessBanner({
); -} +}; diff --git a/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx b/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx index 2b4913ab..d29e5785 100644 --- a/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx +++ b/src/renderer/components/team/dialogs/teammateRuntimeCompatibility.tsx @@ -12,6 +12,7 @@ type TeammateRuntimeIssueReason = | 'mixed-provider' | 'codex-native-runtime' | 'explicit-tmux-mode' + | 'explicit-in-process-mode' | 'opencode-led-mixed-unsupported'; interface RuntimeMemberInput { @@ -176,6 +177,13 @@ export function analyzeTeammateRuntimeCompatibility({ } } + const requiresSeparateProcess = issues.some( + (issue) => issue.reason === 'mixed-provider' || issue.reason === 'codex-native-runtime' + ); + if (explicitTeammateMode === 'in-process' && requiresSeparateProcess) { + issues.push({ reason: 'explicit-in-process-mode' }); + } + if (issues.length === 0) { return { visible: false, @@ -193,7 +201,9 @@ export function analyzeTeammateRuntimeCompatibility({ const hasOpenCodeLeadMixedUnsupported = issues.some( (issue) => issue.reason === 'opencode-led-mixed-unsupported' ); - if (tmuxReady && !hasOpenCodeLeadMixedUnsupported) { + const hasExplicitTmux = issues.some((issue) => issue.reason === 'explicit-tmux-mode'); + const hasExplicitInProcess = issues.some((issue) => issue.reason === 'explicit-in-process-mode'); + if (!hasOpenCodeLeadMixedUnsupported && !hasExplicitTmux && !hasExplicitInProcess) { return { visible: false, blocksSubmission: false, @@ -206,11 +216,28 @@ export function analyzeTeammateRuntimeCompatibility({ }; } - const checking = !hasOpenCodeLeadMixedUnsupported && tmuxStatusLoading && !tmuxStatus; + if (tmuxReady && hasExplicitTmux && !hasOpenCodeLeadMixedUnsupported && !hasExplicitInProcess) { + return { + visible: false, + blocksSubmission: false, + checking: false, + title: '', + message: '', + details: [], + tmuxDetail: null, + memberWarningById: {}, + }; + } + + const checking = + hasExplicitTmux && + !hasOpenCodeLeadMixedUnsupported && + !hasExplicitInProcess && + tmuxStatusLoading && + !tmuxStatus; const blocksSubmission = true; const hasMixedProviders = issues.some((issue) => issue.reason === 'mixed-provider'); const hasCodexNative = issues.some((issue) => issue.reason === 'codex-native-runtime'); - const hasExplicitTmux = issues.some((issue) => issue.reason === 'explicit-tmux-mode'); const details: string[] = []; const memberWarningById: Record = {}; @@ -241,15 +268,20 @@ export function analyzeTeammateRuntimeCompatibility({ if (hasExplicitTmux) { details.push('Custom CLI args force --teammate-mode tmux.'); } + if (hasExplicitInProcess) { + details.push('Custom CLI args force --teammate-mode in-process.'); + } if (hasOpenCodeLeadMixedUnsupported) { details.push( 'Fix: keep the team lead on Anthropic, Codex, or Gemini when mixing OpenCode with other providers.' ); + } else if (hasExplicitInProcess) { + details.push( + 'Fix: remove --teammate-mode in-process so teammates can use native process transport.' + ); } else { details.push( - hasCodexNative && !hasMixedProviders - ? 'Fix: install tmux/WSL tmux, use Solo team, or choose a same-provider runtime that supports in-process teammates.' - : 'Fix: install tmux/WSL tmux, use Solo team, or keep every teammate on the same non-Codex-native provider as the lead.' + 'Fix: install tmux/WSL tmux, or remove --teammate-mode tmux so the app can use native process transport.' ); } @@ -260,10 +292,10 @@ export function analyzeTeammateRuntimeCompatibility({ if (issue.reason === 'mixed-provider') { memberWarningById[issue.memberId] = `${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` + - `Without tmux, teammates must use the same provider as the ${getProviderLabel(leadProviderId)} lead.`; + `This teammate requires a separate process outside the ${getProviderLabel(leadProviderId)} lead.`; } else if (issue.reason === 'codex-native-runtime') { memberWarningById[issue.memberId] = - `${issue.memberName} uses Codex native. Codex native teammates require a separate process, which currently needs tmux.`; + `${issue.memberName} uses Codex native. Codex native teammates require a separate Codex process.`; } else if (issue.reason === 'opencode-led-mixed-unsupported') { memberWarningById[issue.memberId] = `${issue.memberName} uses ${getProviderLabel(issue.memberProviderId ?? leadProviderId)}. ` + @@ -276,19 +308,19 @@ export function analyzeTeammateRuntimeCompatibility({ blocksSubmission, checking, title: checking - ? 'Checking tmux runtime for teammate support' + ? 'Checking tmux runtime for explicit teammate mode' : hasOpenCodeLeadMixedUnsupported ? 'OpenCode cannot lead mixed-provider teams' - : hasCodexNative && !hasMixedProviders - ? 'Codex teammates need tmux before they can run' - : 'This team needs tmux before it can run', + : hasExplicitInProcess + ? 'This team cannot use in-process teammates' + : 'tmux is not ready for explicit teammate mode', message: checking - ? 'Some teammates require separate processes. The app is checking whether tmux is available.' + ? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.' : hasOpenCodeLeadMixedUnsupported ? 'OpenCode teammates can run as secondary runtime lanes under an Anthropic, Codex, or Gemini lead, but OpenCode-led mixed teams are not supported in this phase.' - : hasCodexNative && !hasMixedProviders - ? 'The Codex lead can run without tmux, but Codex native teammates cannot use the in-process teammate adapter. They must start as separate Codex processes, and this path currently needs tmux.' - : 'tmux is not ready on this machine. Same-provider in-process teammates can run without tmux, but this team has teammates that require separate processes.', + : hasExplicitInProcess + ? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.' + : 'Custom CLI args force --teammate-mode tmux, but tmux is not ready. Remove that arg to use native process transport on Windows, or install tmux/WSL tmux.', details, tmuxDetail: getTmuxDetail(tmuxStatus, tmuxStatusError), memberWarningById, diff --git a/src/renderer/components/team/members/CurrentTaskIndicator.tsx b/src/renderer/components/team/members/CurrentTaskIndicator.tsx index d168359a..4781c199 100644 --- a/src/renderer/components/team/members/CurrentTaskIndicator.tsx +++ b/src/renderer/components/team/members/CurrentTaskIndicator.tsx @@ -1,5 +1,5 @@ +import { SyncedLoader2 } from '@renderer/components/ui/SyncedLoader2'; import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity'; -import { Loader2 } from 'lucide-react'; import type { TeamTaskWithKanban } from '@shared/types'; @@ -31,7 +31,7 @@ export const CurrentTaskIndicator = ({ return (
- + {activityLabel}
- +
{displayMemberName(member.name)} - {member.gitBranch ? ( + {member.gitBranch && !showWorkspaceBadge ? ( {member.gitBranch} ) : null} {showWorkspaceBadge ? ( - - worktree - + + + + worktree + + + +
+ {workspaceTooltipLines.map((line) => ( +

+ {line} +

+ ))} +
+
+
) : null} {currentTask ? ( ) : ( - )} @@ -436,8 +438,8 @@ export const MemberCard = ({ className="flex shrink-0 items-center gap-1" title={runtimeEntry?.runtimeDiagnostic} > - {skippingLaunch ? ( - + ) : ( )} @@ -503,7 +505,7 @@ export const MemberCard = ({ onClick={handleRetryFailedLaunch} > {retryingLaunch ? ( - + ) : ( )} @@ -545,7 +547,7 @@ export const MemberCard = ({ onClick={handleRetryFailedLaunch} > {retryingLaunch ? ( - + ) : ( )} diff --git a/src/renderer/components/team/members/MemberDetailDialog.tsx b/src/renderer/components/team/members/MemberDetailDialog.tsx index 4eaea9d7..3828f04c 100644 --- a/src/renderer/components/team/members/MemberDetailDialog.tsx +++ b/src/renderer/components/team/members/MemberDetailDialog.tsx @@ -46,6 +46,37 @@ import type { TeamTaskWithKanban, } from '@shared/types'; +const OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE = + 'No OpenCode runtime session was recorded. Relaunch this teammate to start a fresh OpenCode session.'; + +function hasOpenCodeRuntimeEvidence(runtimeEntry: TeamAgentRuntimeEntry | undefined): boolean { + const hasPid = + typeof runtimeEntry?.pid === 'number' && + Number.isFinite(runtimeEntry.pid) && + runtimeEntry.pid > 0; + const hasRuntimePid = + typeof runtimeEntry?.runtimePid === 'number' && + Number.isFinite(runtimeEntry.runtimePid) && + runtimeEntry.runtimePid > 0; + const hasRuntimeSessionId = + typeof runtimeEntry?.runtimeSessionId === 'string' && + runtimeEntry.runtimeSessionId.trim().length > 0; + const hasRuntimeLiveness = + runtimeEntry?.livenessKind === 'runtime_process' || + runtimeEntry?.livenessKind === 'runtime_process_candidate' || + runtimeEntry?.livenessKind === 'permission_blocked'; + return Boolean(hasPid || hasRuntimePid || hasRuntimeSessionId || hasRuntimeLiveness); +} + +function isOpenCodeNoRuntimeEvidenceFailure( + member: ResolvedTeamMember, + spawnEntry: MemberSpawnStatusEntry | undefined, + runtimeEntry: TeamAgentRuntimeEntry | undefined +): boolean { + const failed = spawnEntry?.launchState === 'failed_to_start' || spawnEntry?.status === 'error'; + return member.providerId === 'opencode' && failed && !hasOpenCodeRuntimeEvidence(runtimeEntry); +} + interface MemberDetailDialogProps { open: boolean; member: ResolvedTeamMember | null; @@ -165,6 +196,13 @@ export const MemberDetailDialog = ({ const launchErrorMessage = launchDiagnosticsPayload ? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload) : undefined; + const openCodeNoRuntimeEvidence = member + ? isOpenCodeNoRuntimeEvidenceFailure(member, spawnEntry, runtimeEntry) + : false; + const effectiveLaunchErrorMessage = openCodeNoRuntimeEvidence + ? OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE + : launchErrorMessage; + const restartButtonLabel = openCodeNoRuntimeEvidence ? 'Relaunch OpenCode' : 'Restart'; useEffect(() => { if (!open || !member) { @@ -284,10 +322,10 @@ export const MemberDetailDialog = ({ {restartError ? (
{restartError}
- ) : launchErrorMessage ? ( + ) : effectiveLaunchErrorMessage ? (
- - {launchErrorMessage} + + {effectiveLaunchErrorMessage} {launchDiagnosticsPayload && showCopyDiagnostics ? ( )} - Restart + {restartButtonLabel} )}
diff --git a/src/renderer/components/team/members/MemberDraftRow.tsx b/src/renderer/components/team/members/MemberDraftRow.tsx index e6c9fd3f..d6e5fe9e 100644 --- a/src/renderer/components/team/members/MemberDraftRow.tsx +++ b/src/renderer/components/team/members/MemberDraftRow.tsx @@ -73,6 +73,7 @@ interface MemberDraftRowProps { onRestore?: (id: string) => void; hideActionButton?: boolean; warningText?: string | null; + infoText?: string | null; disableGeminiOption?: boolean; modelIssueText?: string | null; showWorktreeIsolationControls?: boolean; @@ -122,6 +123,7 @@ export const MemberDraftRow = ({ onRestore, hideActionButton = false, warningText, + infoText, disableGeminiOption = false, modelIssueText, showWorktreeIsolationControls = false, @@ -419,6 +421,14 @@ export const MemberDraftRow = ({
) : null} + {!isRemoved && infoText ? ( +
+
+ +

{infoText}

+
+
+ ) : null} {showWorkflow && onWorkflowChange && workflowExpanded ? (
diff --git a/src/renderer/components/team/members/MemberPresenceDot.tsx b/src/renderer/components/team/members/MemberPresenceDot.tsx new file mode 100644 index 00000000..aab1f38b --- /dev/null +++ b/src/renderer/components/team/members/MemberPresenceDot.tsx @@ -0,0 +1,28 @@ +import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle'; +import { cn } from '@renderer/lib/utils'; + +const PULSE_DURATION_MS = 2000; + +interface MemberPresenceDotProps { + className?: string; + label: string; +} + +export const MemberPresenceDot = ({ + className, + label, +}: MemberPresenceDotProps): React.JSX.Element => { + const shouldSyncPulse = className?.includes('animate-pulse') === true; + const syncedPulseStyle = useSyncedAnimationStyle(shouldSyncPulse, PULSE_DURATION_MS); + + return ( + + ); +}; diff --git a/src/renderer/components/team/members/MembersEditorSection.tsx b/src/renderer/components/team/members/MembersEditorSection.tsx index 6624d788..3db86d26 100644 --- a/src/renderer/components/team/members/MembersEditorSection.tsx +++ b/src/renderer/components/team/members/MembersEditorSection.tsx @@ -111,6 +111,7 @@ export interface MembersEditorSectionProps { modelLockReason?: string; softDeleteMembers?: boolean; memberWarningById?: Record; + memberInfoById?: Record; disableGeminiOption?: boolean; memberModelIssueById?: Record; disableAddMember?: boolean; @@ -149,6 +150,7 @@ export const MembersEditorSection = ({ modelLockReason, softDeleteMembers = false, memberWarningById, + memberInfoById, disableGeminiOption = false, memberModelIssueById, disableAddMember = false, @@ -415,6 +417,7 @@ export const MembersEditorSection = ({ identityLockReason={identityLockReason} modelLockReason={modelLockReason} warningText={memberWarningById?.[member.id] ?? null} + infoText={memberInfoById?.[member.id] ?? null} disableGeminiOption={disableGeminiOption} modelIssueText={memberModelIssueById?.[member.id] ?? null} /> diff --git a/src/renderer/components/team/members/TeamRosterEditorSection.tsx b/src/renderer/components/team/members/TeamRosterEditorSection.tsx index ff748ee0..1ce5b61a 100644 --- a/src/renderer/components/team/members/TeamRosterEditorSection.tsx +++ b/src/renderer/components/team/members/TeamRosterEditorSection.tsx @@ -43,6 +43,7 @@ interface TeamRosterEditorSectionProps { softDeleteMembers?: boolean; leadWarningText?: string | null; memberWarningById?: Record; + memberInfoById?: Record; disableGeminiOption?: boolean; leadModelIssueText?: string | null; memberModelIssueById?: Record; @@ -88,6 +89,7 @@ export const TeamRosterEditorSection = ({ softDeleteMembers = false, leadWarningText, memberWarningById, + memberInfoById, disableGeminiOption = false, leadModelIssueText, memberModelIssueById, @@ -148,6 +150,7 @@ export const TeamRosterEditorSection = ({
} memberWarningById={memberWarningById} + memberInfoById={memberInfoById} /> ); }; diff --git a/src/renderer/components/team/messages/MessageComposer.tsx b/src/renderer/components/team/messages/MessageComposer.tsx index ebdcbac0..24df1ab2 100644 --- a/src/renderer/components/team/messages/MessageComposer.tsx +++ b/src/renderer/components/team/messages/MessageComposer.tsx @@ -19,7 +19,6 @@ import { isTeamProvisioningActive } from '@renderer/store/slices/teamSlice'; import { serializeChipsWithText } from '@renderer/types/inlineChip'; import { formatAgentRole } from '@renderer/utils/formatAgentRole'; import { buildMemberColorMap } from '@renderer/utils/memberHelpers'; -import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import { nameColorSet } from '@renderer/utils/projectColor'; import { getSuggestedSlashCommandsForProvider } from '@renderer/utils/providerSlashCommands'; import { buildSlashCommandSuggestions } from '@renderer/utils/skillCommandSuggestions'; @@ -39,6 +38,7 @@ import { useShallow } from 'zustand/react/shallow'; import type { ActionMode } from '@renderer/components/team/messages/ActionModeSelector'; import type { MentionSuggestion } from '@renderer/types/mention'; +import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import type { AttachmentPayload, ResolvedTeamMember, diff --git a/src/renderer/components/team/messages/MessagesPanel.tsx b/src/renderer/components/team/messages/MessagesPanel.tsx index dbe67299..1784de68 100644 --- a/src/renderer/components/team/messages/MessagesPanel.tsx +++ b/src/renderer/components/team/messages/MessagesPanel.tsx @@ -18,7 +18,6 @@ import { useTeamMessagesExpanded } from '@renderer/hooks/useTeamMessagesExpanded import { useTeamMessagesRead } from '@renderer/hooks/useTeamMessagesRead'; import { useStore } from '@renderer/store'; import { selectTeamMessages } from '@renderer/store/slices/teamSlice'; -import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import { filterTeamMessages } from '@renderer/utils/teamMessageFiltering'; import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { shouldExcludeInboxTextFromReplyCandidates } from '@shared/utils/idleNotificationSemantics'; @@ -59,6 +58,7 @@ import type { TimelineItem } from '../activity/LeadThoughtsGroup'; import type { ActionMode } from './ActionModeSelector'; import type { MessagesFilterState } from './MessagesFilterPopover'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; +import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import type { InboxMessage, ResolvedTeamMember, TaskRef, TeamTaskWithKanban } from '@shared/types'; interface TimeWindow { @@ -186,8 +186,7 @@ export function hasVisibleReplyForSendMessageDiagnostics( const sentMessage = messages.find((message) => message.messageId === messageId); if ( - !sentMessage || - sentMessage.from !== 'user' || + sentMessage?.from !== 'user' || typeof sentMessage.to !== 'string' || sentMessage.to.length === 0 ) { diff --git a/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx b/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx index 1e92bc7a..8d1e2197 100644 --- a/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx +++ b/src/renderer/components/team/messages/OpenCodeDeliveryWarning.tsx @@ -14,11 +14,11 @@ interface OpenCodeDeliveryWarningProps { pendingDelayMs?: number; } -export function OpenCodeDeliveryWarning({ +export const OpenCodeDeliveryWarning = ({ warning, debugDetails, pendingDelayMs = 10_000, -}: OpenCodeDeliveryWarningProps): JSX.Element | null { +}: OpenCodeDeliveryWarningProps): JSX.Element | null => { const detailsKey = `${warning ?? ''}:${debugDetails?.messageId ?? ''}`; const delayPendingWarning = debugDetails?.responsePending === true && debugDetails.delivered !== false; @@ -148,4 +148,4 @@ export function OpenCodeDeliveryWarning({ ) : null} ); -} +}; diff --git a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx index e02dacd3..e2aba919 100644 --- a/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx +++ b/src/renderer/components/team/taskLogs/TaskLogStreamSection.tsx @@ -81,6 +81,9 @@ function describeStreamSource(stream: BoardTaskLogStreamResponse | null): string } return 'Task-scoped OpenCode runtime logs projected into the same execution-log components used in Logs.'; } + if (stream?.runtimeProjection?.provider === 'opencode') { + return 'Task-scoped transcript logs merged with OpenCode runtime logs and rendered with the same execution-log components used in Logs.'; + } return 'Task-scoped transcript logs rendered with the same execution-log components used in Logs.'; } diff --git a/src/renderer/components/ui/SyncedLoader2.tsx b/src/renderer/components/ui/SyncedLoader2.tsx new file mode 100644 index 00000000..50144c34 --- /dev/null +++ b/src/renderer/components/ui/SyncedLoader2.tsx @@ -0,0 +1,28 @@ +import { useSyncedAnimationStyle } from '@renderer/hooks/useSyncedAnimationStyle'; +import { cn } from '@renderer/lib/utils'; +import { Loader2 } from 'lucide-react'; + +import type { ComponentProps } from 'react'; + +const DEFAULT_SPIN_DURATION_MS = 1000; + +export type SyncedLoader2Props = ComponentProps & { + spinDurationMs?: number; +}; + +export const SyncedLoader2 = ({ + className, + style, + spinDurationMs = DEFAULT_SPIN_DURATION_MS, + ...props +}: SyncedLoader2Props): React.JSX.Element => { + const syncedStyle = useSyncedAnimationStyle(true, spinDurationMs); + + return ( + + ); +}; diff --git a/src/renderer/hooks/useKeyboardShortcuts.ts b/src/renderer/hooks/useKeyboardShortcuts.ts index d1c4d10a..70956717 100644 --- a/src/renderer/hooks/useKeyboardShortcuts.ts +++ b/src/renderer/hooks/useKeyboardShortcuts.ts @@ -29,7 +29,7 @@ export function isEditableShortcutTarget(target: EventTarget | null): boolean { } const contentEditable = editableElement.getAttribute('contenteditable'); - return contentEditable == null || contentEditable.toLowerCase() !== 'false'; + return contentEditable?.toLowerCase() !== 'false'; } export function useKeyboardShortcuts(): void { diff --git a/src/renderer/hooks/useSyncedAnimationStyle.ts b/src/renderer/hooks/useSyncedAnimationStyle.ts new file mode 100644 index 00000000..17374ed4 --- /dev/null +++ b/src/renderer/hooks/useSyncedAnimationStyle.ts @@ -0,0 +1,29 @@ +import { useMemo } from 'react'; + +import type { CSSProperties } from 'react'; + +const DEFAULT_ANIMATION_DURATION_MS = 1000; + +function getCurrentTimeMs(): number { + return typeof performance !== 'undefined' && typeof performance.now === 'function' + ? performance.now() + : Date.now(); +} + +export function useSyncedAnimationStyle( + enabled: boolean, + durationMs = DEFAULT_ANIMATION_DURATION_MS +): CSSProperties | undefined { + return useMemo(() => { + if (!enabled) { + return undefined; + } + const safeDurationMs = + Number.isFinite(durationMs) && durationMs > 0 ? durationMs : DEFAULT_ANIMATION_DURATION_MS; + const phaseMs = getCurrentTimeMs() % safeDurationMs; + return { + animationDelay: `${-phaseMs}ms`, + animationDuration: `${safeDurationMs}ms`, + }; + }, [durationMs, enabled]); +} diff --git a/src/renderer/store/index.ts b/src/renderer/store/index.ts index 0b9424ff..75751dbc 100644 --- a/src/renderer/store/index.ts +++ b/src/renderer/store/index.ts @@ -19,9 +19,9 @@ import { create } from 'zustand'; import { createChangeReviewSlice } from './slices/changeReviewSlice'; import { createCliInstallerSlice, - getIncompleteMultimodelProviderIds, getModelOnlyFallbackProviderIds, mergeCliStatusPreservingHydratedProviders, + reconcileMultimodelProviderLoading, } from './slices/cliInstallerSlice'; import { createConfigSlice } from './slices/configSlice'; import { createConnectionSlice } from './slices/connectionSlice'; @@ -1485,20 +1485,14 @@ export function initializeNotificationListeners(): () => void { state.cliStatus, progress.status! ); - const incompleteProviderIds = getIncompleteMultimodelProviderIds(nextStatus); modelOnlyFallbackProviderIds = getModelOnlyFallbackProviderIds(nextStatus); return { cliStatus: nextStatus, - cliProviderStatusLoading: - incompleteProviderIds.length > 0 - ? { - ...state.cliProviderStatusLoading, - ...Object.fromEntries( - incompleteProviderIds.map((providerId) => [providerId, true]) - ), - } - : state.cliProviderStatusLoading, + cliProviderStatusLoading: reconcileMultimodelProviderLoading( + nextStatus, + state.cliProviderStatusLoading + ), }; }); for (const providerId of modelOnlyFallbackProviderIds) { diff --git a/src/renderer/store/slices/cliInstallerSlice.ts b/src/renderer/store/slices/cliInstallerSlice.ts index b52440d5..0cad9079 100644 --- a/src/renderer/store/slices/cliInstallerSlice.ts +++ b/src/renderer/store/slices/cliInstallerSlice.ts @@ -125,6 +125,24 @@ export function getModelOnlyFallbackProviderIds( .map((provider) => provider.providerId); } +export function reconcileMultimodelProviderLoading( + status: CliInstallationStatus | null, + currentLoading: Partial> +): Partial> { + if (status?.flavor !== 'agent_teams_orchestrator' || !status.installed) { + return {}; + } + + const incompleteProviderIds = new Set(getIncompleteMultimodelProviderIds(status)); + return status.providers.reduce>>( + (nextLoading, provider) => ({ + ...nextLoading, + [provider.providerId]: incompleteProviderIds.has(provider.providerId), + }), + { ...currentLoading } + ); +} + export function mergeCliStatusPreservingHydratedProviders( current: CliInstallationStatus | null, incoming: CliInstallationStatus diff --git a/src/renderer/store/slices/teamSlice.ts b/src/renderer/store/slices/teamSlice.ts index 8e0f52fa..b9adaedf 100644 --- a/src/renderer/store/slices/teamSlice.ts +++ b/src/renderer/store/slices/teamSlice.ts @@ -1,5 +1,6 @@ import { api } from '@renderer/api'; import { mergeTeamMessages } from '@renderer/utils/mergeTeamMessages'; +import { buildOpenCodeRuntimeDeliveryDiagnostics } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import { normalizePath } from '@renderer/utils/pathNormalize'; import { buildTaskChangePresenceKey, @@ -10,7 +11,6 @@ import { import { toMessageKey } from '@renderer/utils/teamMessageKey'; import { extractProviderScopedBaseModel } from '@renderer/utils/teamModelContext'; import { IpcError, unwrapIpc } from '@renderer/utils/unwrapIpc'; -import { buildOpenCodeRuntimeDeliveryDiagnostics } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import { stripAgentBlocks } from '@shared/constants/agentBlocks'; import { DEFAULT_TOOL_APPROVAL_SETTINGS } from '@shared/types/team'; import { isLeadMember } from '@shared/utils/leadDetection'; @@ -25,8 +25,8 @@ import { getWorktreeNavigationState } from '../utils/stateResetHelpers'; import type { AppState } from '../types'; import type { GraphLayoutMode, GraphOwnerSlotAssignment } from '@claude-teams/agent-graph'; import type { AppConfig } from '@renderer/types/data'; -import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import type { TeamMessagesPanelMode } from '@renderer/types/teamMessagesPanelMode'; +import type { OpenCodeRuntimeDeliveryDebugDetails } from '@renderer/utils/openCodeRuntimeDeliveryDiagnostics'; import type { ActiveToolCall, AddMemberRequest, diff --git a/src/renderer/utils/memberHelpers.ts b/src/renderer/utils/memberHelpers.ts index 2af7729f..95463599 100644 --- a/src/renderer/utils/memberHelpers.ts +++ b/src/renderer/utils/memberHelpers.ts @@ -117,7 +117,7 @@ export const SPAWN_DOT_COLORS: Record = { offline: 'bg-zinc-600', waiting: 'bg-zinc-400 animate-pulse', spawning: 'bg-amber-400', - online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]', + online: 'bg-emerald-400 animate-pulse', error: 'bg-red-400', skipped: 'bg-zinc-500', }; diff --git a/src/renderer/utils/memberLaunchDiagnostics.ts b/src/renderer/utils/memberLaunchDiagnostics.ts index 8eb52c00..97f04069 100644 --- a/src/renderer/utils/memberLaunchDiagnostics.ts +++ b/src/renderer/utils/memberLaunchDiagnostics.ts @@ -55,7 +55,7 @@ function boundedNumber(value: number | undefined): number | undefined { } function uniqueDiagnostics( - ...groups: Array + ...groups: (readonly (string | undefined)[] | undefined)[] ): string[] | undefined { const seen = new Set(); const diagnostics: string[] = []; diff --git a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts index bc87c17b..a7317841 100644 --- a/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts +++ b/src/renderer/utils/openCodeRuntimeDeliveryDiagnostics.ts @@ -26,7 +26,7 @@ export function buildOpenCodeRuntimeDeliveryDiagnostics( result: SendMessageResult ): OpenCodeRuntimeDeliveryDiagnostics { const runtimeDelivery = result.runtimeDelivery; - if (!runtimeDelivery || runtimeDelivery.attempted !== true) { + if (runtimeDelivery?.attempted !== true) { return { warning: null, debugDetails: null }; } diff --git a/src/renderer/utils/streamJsonParser.ts b/src/renderer/utils/streamJsonParser.ts index 54b3645a..ab3fa125 100644 --- a/src/renderer/utils/streamJsonParser.ts +++ b/src/renderer/utils/streamJsonParser.ts @@ -51,6 +51,36 @@ interface ContentBlock { input?: Record; } +interface CodexNativeJsonEvent { + type?: string; + thread_id?: string; + item?: { + id?: string; + type?: string; + text?: string; + server?: string; + tool?: string; + arguments?: unknown; + result?: unknown; + error?: unknown; + status?: string; + }; + usage?: { + input_tokens?: number; + cached_input_tokens?: number; + output_tokens?: number; + }; +} + +interface CodexNativeProjectedSystemEvent { + type?: string; + subtype?: string; + content?: string; + level?: string; + codexNativeThreadStatus?: string; + codexNativeThreadId?: string; +} + /** * Content-based hash for deterministic fallback IDs that survive * line reordering and pagination changes. @@ -95,6 +125,170 @@ function extractContentBlocks(parsed: unknown): ContentBlock[] | null { return null; } +function asRecord(value: unknown): Record | null { + return value && typeof value === 'object' && !Array.isArray(value) + ? (value as Record) + : null; +} + +function extractCodexToolResultText(value: unknown): string | null { + if (typeof value === 'string' && value.trim()) { + return value.trim(); + } + + const record = asRecord(value); + const content = record?.content; + if (Array.isArray(content)) { + const text = content + .map((block) => asRecord(block)) + .map((block) => (block?.type === 'text' && typeof block.text === 'string' ? block.text : '')) + .filter((entry) => entry.trim().length > 0) + .join('\n') + .trim(); + if (text) return text; + } + + if (record?.structured_content != null) { + return JSON.stringify(record.structured_content); + } + + return null; +} + +function extractCodexToolErrorText(value: unknown): string | null { + const record = asRecord(value); + const message = record?.message; + return typeof message === 'string' && message.trim() ? message.trim() : null; +} + +function getCodexToolDisplayName(serverName: string, toolName: string): string { + return serverName === 'agent-teams' ? `agent-teams_${toolName}` : `${serverName}_${toolName}`; +} + +function createCodexToolItem( + event: CodexNativeJsonEvent, + timestamp: Date, + lineIndex: number +): AIGroupDisplayItem | null { + const item = event.item; + if ( + (event.type !== 'item.started' && event.type !== 'item.completed') || + item?.type !== 'mcp_tool_call' || + typeof item.server !== 'string' || + typeof item.tool !== 'string' + ) { + return null; + } + + const input = asRecord(item.arguments) ?? {}; + const status = typeof item.status === 'string' && item.status.trim() ? item.status : 'unknown'; + const errorText = extractCodexToolErrorText(item.error); + const resultText = extractCodexToolResultText(item.result); + const isCompleted = event.type === 'item.completed'; + const toolName = getCodexToolDisplayName(item.server, item.tool); + const linkedTool: LinkedToolItem = { + id: item.id ?? `codex-tool-L${lineIndex}`, + name: toolName, + input, + inputPreview: getToolSummary(toolName, input), + startTime: timestamp, + isOrphaned: !isCompleted, + }; + + if (isCompleted) { + linkedTool.endTime = timestamp; + linkedTool.isOrphaned = false; + if (resultText || errorText) { + linkedTool.result = { + content: resultText ?? errorText ?? '', + isError: status === 'failed' || errorText !== null, + }; + linkedTool.outputPreview = resultText ?? errorText ?? undefined; + } + } + + return { type: 'tool', tool: linkedTool }; +} + +function codexNativeEventToDisplayItems( + parsed: unknown, + timestamp: Date, + lineIndex: number +): AIGroupDisplayItem[] | null { + const event = asRecord(parsed) as CodexNativeJsonEvent | null; + if (!event || typeof event.type !== 'string') { + return null; + } + + if (event.type === 'thread.started') { + const threadId = + typeof event.thread_id === 'string' && event.thread_id.trim() + ? `: ${event.thread_id.trim()}` + : ''; + return [{ type: 'output', content: `Codex native thread started${threadId}.`, timestamp }]; + } + + if (event.type === 'turn.started') { + return [{ type: 'output', content: 'Codex turn started.', timestamp }]; + } + + if (event.type === 'turn.completed') { + const usage = event.usage; + const usageParts = [ + typeof usage?.input_tokens === 'number' ? `${usage.input_tokens} input` : null, + typeof usage?.cached_input_tokens === 'number' ? `${usage.cached_input_tokens} cached` : null, + typeof usage?.output_tokens === 'number' ? `${usage.output_tokens} output` : null, + ].filter((part): part is string => Boolean(part)); + const suffix = usageParts.length > 0 ? ` (${usageParts.join(', ')} tokens)` : ''; + return [{ type: 'output', content: `Codex turn completed${suffix}.`, timestamp }]; + } + + if ( + event.type === 'item.completed' && + event.item?.type === 'agent_message' && + typeof event.item.text === 'string' && + event.item.text.trim() + ) { + return [{ type: 'output', content: event.item.text.trim(), timestamp }]; + } + + const toolItem = createCodexToolItem(event, timestamp, lineIndex); + if (toolItem) { + return [toolItem]; + } + + return null; +} + +function codexNativeProjectedSystemToDisplayItems( + parsed: unknown, + timestamp: Date +): AIGroupDisplayItem[] | null { + const event = asRecord(parsed) as CodexNativeProjectedSystemEvent | null; + if (event?.type !== 'system' || typeof event.subtype !== 'string') { + return null; + } + + if ( + event.subtype !== 'codex_native_thread_status' && + event.subtype !== 'codex_native_warning' && + event.subtype !== 'codex_native_execution_summary' + ) { + return null; + } + + const content = + typeof event.content === 'string' && event.content.trim() + ? event.content.trim() + : event.subtype === 'codex_native_thread_status' + ? `Codex native thread ${event.codexNativeThreadStatus ?? 'status'}${ + event.codexNativeThreadId ? `: ${event.codexNativeThreadId}` : '' + }.` + : null; + + return content ? [{ type: 'output', content, timestamp }] : null; +} + /** * Converts content blocks from a single assistant message into display items. * @param lineIndex - stable line position for deterministic fallback IDs @@ -220,6 +414,64 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] const msgIdOccurrences = new Map(); const hashOccurrences = new Map(); + const ensureCurrentTimestamp = (line: string): Date => { + if (currentTimestamp) return currentTimestamp; + // Use stable cached timestamp keyed by line content to survive re-parses + let ts = lineTimestampCache.get(line); + if (!ts) { + ts = new Date(); + if (lineTimestampCache.size >= MAX_TIMESTAMP_CACHE_SIZE) { + // Evict oldest entry (first inserted) + const firstKey = lineTimestampCache.keys().next().value!; + lineTimestampCache.delete(firstKey); + } + lineTimestampCache.set(line, ts); + } + currentTimestamp = ts; + return ts; + }; + + const ensureCurrentGroupId = ( + line: string, + parsed: unknown, + lineAgentId: string | undefined + ): void => { + if (currentGroupId) return; + currentAgentId = lineAgentId; + const msgId = extractAssistantMessageId(parsed); + if (msgId) { + const occurrence = msgIdOccurrences.get(msgId) ?? 0; + msgIdOccurrences.set(msgId, occurrence + 1); + currentGroupId = + occurrence === 0 ? `stream-group-${msgId}` : `stream-group-${msgId}-${occurrence}`; + return; + } + + // Content-hash fallback: deterministic and survives line reordering + const h = stableHash(line); + const occ = hashOccurrences.get(h) ?? 0; + hashOccurrences.set(h, occ + 1); + currentGroupId = occ === 0 ? `stream-group-H${h}` : `stream-group-H${h}-${occ}`; + }; + + const pushItems = (items: AIGroupDisplayItem[]): void => { + for (const item of items) { + if (item.type !== 'tool') { + currentItems.push(item); + continue; + } + + const existingIndex = currentItems.findIndex( + (entry) => entry.type === 'tool' && entry.tool.id === item.tool.id + ); + if (existingIndex === -1) { + currentItems.push(item); + } else { + currentItems[existingIndex] = item; + } + } + }; + const flushGroup = (): void => { if (currentItems.length > 0 && currentTimestamp) { const id = currentGroupId ?? `stream-group-fallback-${groups.length}`; @@ -268,7 +520,17 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] const blocks = extractContentBlocks(parsed); if (!blocks) { - // Valid JSON but not an assistant message β€” flush and skip + const timestamp = ensureCurrentTimestamp(trimmed); + const codexItems = + codexNativeEventToDisplayItems(parsed, timestamp, lineIndex) ?? + codexNativeProjectedSystemToDisplayItems(parsed, timestamp); + if (codexItems && codexItems.length > 0) { + ensureCurrentGroupId(trimmed, parsed, undefined); + pushItems(codexItems); + continue; + } + + // Valid JSON but not a displayable log event β€” flush and skip flushGroup(); continue; } @@ -279,39 +541,11 @@ export function parseStreamJsonToGroups(cliLogsTail: string): StreamJsonGroup[] ? ((parsed as Record).agentId as string) : undefined; - if (!currentTimestamp) { - // Use stable cached timestamp keyed by line content to survive re-parses - let ts = lineTimestampCache.get(trimmed); - if (!ts) { - ts = new Date(); - if (lineTimestampCache.size >= MAX_TIMESTAMP_CACHE_SIZE) { - // Evict oldest entry (first inserted) - const firstKey = lineTimestampCache.keys().next().value!; - lineTimestampCache.delete(firstKey); - } - lineTimestampCache.set(trimmed, ts); - } - currentTimestamp = ts; - } - if (!currentGroupId) { - currentAgentId = lineAgentId; - const msgId = extractAssistantMessageId(parsed); - if (msgId) { - const occurrence = msgIdOccurrences.get(msgId) ?? 0; - msgIdOccurrences.set(msgId, occurrence + 1); - currentGroupId = - occurrence === 0 ? `stream-group-${msgId}` : `stream-group-${msgId}-${occurrence}`; - } else { - // Content-hash fallback: deterministic and survives line reordering - const h = stableHash(trimmed); - const occ = hashOccurrences.get(h) ?? 0; - hashOccurrences.set(h, occ + 1); - currentGroupId = occ === 0 ? `stream-group-H${h}` : `stream-group-H${h}-${occ}`; - } - } + const timestamp = ensureCurrentTimestamp(trimmed); + ensureCurrentGroupId(trimmed, parsed, lineAgentId); - const items = contentBlocksToDisplayItems(blocks, currentTimestamp, lineIndex); - currentItems.push(...items); + const items = contentBlocksToDisplayItems(blocks, timestamp, lineIndex); + pushItems(items); } // Flush remaining items diff --git a/src/renderer/utils/teamListStatus.ts b/src/renderer/utils/teamListStatus.ts new file mode 100644 index 00000000..bab7c4a4 --- /dev/null +++ b/src/renderer/utils/teamListStatus.ts @@ -0,0 +1,78 @@ +import type { LeadActivityState, TeamProvisioningProgress, TeamSummary } from '@shared/types'; + +export type TeamStatus = + | 'active' + | 'idle' + | 'provisioning' + | 'offline' + | 'partial_failure' + | 'partial_skipped' + | 'partial_pending'; + +const ACTIVE_PROVISIONING_STATES = new Set([ + 'validating', + 'spawning', + 'configuring', + 'assembling', + 'finalizing', + 'verifying', +]); + +const READY_RUNNING_GRACE_MS = 45_000; + +function isRecentReadyProgress( + currentProgress: TeamProvisioningProgress | null, + nowMs: number +): boolean { + if (currentProgress?.state !== 'ready') { + return false; + } + + const updatedAtMs = Date.parse(currentProgress.updatedAt); + return Number.isFinite(updatedAtMs) && nowMs - updatedAtMs <= READY_RUNNING_GRACE_MS; +} + +export function resolveTeamStatus( + team: TeamSummary, + teamName: string, + aliveTeams: string[], + currentProgress: TeamProvisioningProgress | null, + leadActivityByTeam: Partial>, + nowMs: number = Date.now() +): TeamStatus { + if (currentProgress && ACTIVE_PROVISIONING_STATES.has(currentProgress.state)) { + return 'provisioning'; + } + + const leadActivity = leadActivityByTeam[teamName]; + if (leadActivity === 'offline') { + return 'offline'; + } + + if (aliveTeams.includes(teamName)) { + return leadActivity === 'active' ? 'active' : 'idle'; + } + + if (team.teamLaunchState === 'partial_pending') { + return 'partial_pending'; + } + if (team.teamLaunchState === 'partial_skipped') { + return 'partial_skipped'; + } + if (team.partialLaunchFailure || team.teamLaunchState === 'partial_failure') { + return 'partial_failure'; + } + + // The alive-list API is refreshed asynchronously after terminal launch progress. + // Keep a short optimistic running state to avoid a false Offline flicker between + // progress=ready and the next authoritative alive-list response. + if (isRecentReadyProgress(currentProgress, nowMs)) { + return leadActivity === 'active' ? 'active' : 'idle'; + } + + return 'offline'; +} + +export function isTeamListStatusRunning(status: TeamStatus): boolean { + return status !== 'offline' && status !== 'partial_failure' && status !== 'partial_pending'; +} diff --git a/src/renderer/utils/toolRendering/toolContentChecks.ts b/src/renderer/utils/toolRendering/toolContentChecks.ts index 090cf546..4dd5050d 100644 --- a/src/renderer/utils/toolRendering/toolContentChecks.ts +++ b/src/renderer/utils/toolRendering/toolContentChecks.ts @@ -49,7 +49,13 @@ export function hasEditContent(linkedTool: LinkedToolItem): boolean { * Checks if a Write tool has content to display. */ export function hasWriteContent(linkedTool: LinkedToolItem): boolean { - if (linkedTool.input.content != null || linkedTool.input.file_path != null) return true; + if ( + linkedTool.input.content != null || + linkedTool.input.file_path != null || + linkedTool.input.filePath != null || + linkedTool.input.path != null + ) + return true; const toolUseResult = linkedTool.result?.toolUseResult as Record | undefined; if (toolUseResult?.content != null || toolUseResult?.filePath != null) return true; diff --git a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts index fe9aa778..44badd66 100644 --- a/src/renderer/utils/toolRendering/toolSummaryHelpers.ts +++ b/src/renderer/utils/toolRendering/toolSummaryHelpers.ts @@ -15,15 +15,27 @@ function truncate(str: string, maxLength: number): string { return str.slice(0, maxLength) + '...'; } +function readString(input: Record, keys: string[]): string | undefined { + for (const key of keys) { + const value = input[key]; + if (typeof value === 'string' && value.length > 0) { + return value; + } + } + return undefined; +} + /** * Generates a human-readable summary for a tool call. */ export function getToolSummary(toolName: string, input: Record): string { - switch (toolName) { - case 'Edit': { - const filePath = input.file_path as string | undefined; - const oldString = input.old_string as string | undefined; - const newString = input.new_string as string | undefined; + const normalizedToolName = toolName.toLowerCase(); + + switch (normalizedToolName) { + case 'edit': { + const filePath = readString(input, ['file_path', 'filePath', 'path']); + const oldString = readString(input, ['old_string', 'oldString']); + const newString = readString(input, ['new_string', 'newString']); if (!filePath) return 'Edit'; @@ -42,8 +54,8 @@ export function getToolSummary(toolName: string, input: Record) return fileName; } - case 'Read': { - const filePath = input.file_path as string | undefined; + case 'read': { + const filePath = readString(input, ['file_path', 'filePath', 'path']); const limit = input.limit as number | undefined; const offset = input.offset as number | undefined; @@ -59,11 +71,15 @@ export function getToolSummary(toolName: string, input: Record) return fileName; } - case 'Write': { - const filePath = input.file_path as string | undefined; - const content = input.content as string | undefined; + case 'write': { + const filePath = readString(input, ['file_path', 'filePath', 'path']); + const content = readString(input, ['content', 'text']); - if (!filePath) return 'Write'; + if (!filePath) { + return content + ? `content - ${content.split('\n').length} lines` + : 'Write input unavailable'; + } const fileName = getBaseName(filePath); @@ -75,9 +91,9 @@ export function getToolSummary(toolName: string, input: Record) return fileName; } - case 'Bash': { - const command = input.command as string | undefined; - const description = input.description as string | undefined; + case 'bash': { + const command = readString(input, ['command']); + const description = readString(input, ['description']); // Prefer description if available if (description) { @@ -91,10 +107,10 @@ export function getToolSummary(toolName: string, input: Record) return 'Bash'; } - case 'Grep': { - const pattern = input.pattern as string | undefined; - const path = input.path as string | undefined; - const glob = input.glob as string | undefined; + case 'grep': { + const pattern = readString(input, ['pattern']); + const path = readString(input, ['path']); + const glob = readString(input, ['glob']); if (!pattern) return 'Grep'; @@ -110,9 +126,9 @@ export function getToolSummary(toolName: string, input: Record) return patternStr; } - case 'Glob': { - const pattern = input.pattern as string | undefined; - const path = input.path as string | undefined; + case 'glob': { + const pattern = readString(input, ['pattern']); + const path = readString(input, ['path']); if (!pattern) return 'Glob'; @@ -125,7 +141,7 @@ export function getToolSummary(toolName: string, input: Record) return patternStr; } - case 'Task': { + case 'task': { const prompt = input.prompt as string | undefined; const subagentType = input.subagentType as string | undefined; const description = input.description as string | undefined; @@ -140,7 +156,7 @@ export function getToolSummary(toolName: string, input: Record) return subagentType ?? 'Task'; } - case 'LSP': { + case 'lsp': { const operation = input.operation as string | undefined; const filePath = input.filePath as string | undefined; @@ -153,7 +169,7 @@ export function getToolSummary(toolName: string, input: Record) return operation; } - case 'WebFetch': { + case 'webfetch': { const url = input.url as string | undefined; if (url) { @@ -168,7 +184,7 @@ export function getToolSummary(toolName: string, input: Record) return 'WebFetch'; } - case 'WebSearch': { + case 'websearch': { const query = input.query as string | undefined; if (query) { @@ -178,7 +194,7 @@ export function getToolSummary(toolName: string, input: Record) return 'WebSearch'; } - case 'TodoWrite': { + case 'todowrite': { const todos = input.todos as unknown[] | undefined; if (todos && Array.isArray(todos)) { @@ -188,7 +204,7 @@ export function getToolSummary(toolName: string, input: Record) return 'TodoWrite'; } - case 'NotebookEdit': { + case 'notebookedit': { const notebookPath = input.notebook_path as string | undefined; const editMode = input.edit_mode as string | undefined; @@ -204,19 +220,19 @@ export function getToolSummary(toolName: string, input: Record) // Team Tools // ========================================================================= - case 'TeamCreate': { + case 'teamcreate': { const teamName = input.team_name as string | undefined; const desc = input.description as string | undefined; if (teamName) return `${teamName}${desc ? ' - ' + truncate(desc, 30) : ''}`; return 'Create team'; } - case 'TaskCreate': { + case 'taskcreate': { const subject = input.subject as string | undefined; return subject ? truncate(subject, 50) : 'Create task'; } - case 'TaskUpdate': { + case 'taskupdate': { const taskId = input.taskId as string | undefined; const status = input.status as string | undefined; const owner = input.owner as string | undefined; @@ -227,15 +243,15 @@ export function getToolSummary(toolName: string, input: Record) return parts.length > 0 ? parts.join(' ') : 'Update task'; } - case 'TaskList': + case 'tasklist': return 'List tasks'; - case 'TaskGet': { + case 'taskget': { const taskId = input.taskId as string | undefined; return taskId ? `Get task #${taskId}` : 'Get task'; } - case 'SendMessage': { + case 'sendmessage': { const msgType = input.type as string | undefined; const recipient = input.recipient as string | undefined; const summary = input.summary as string | undefined; @@ -246,10 +262,10 @@ export function getToolSummary(toolName: string, input: Record) return 'Send message'; } - case 'TeamDelete': + case 'teamdelete': return 'Delete team'; - case 'Agent': { + case 'agent': { return summarizeAgentToolInput(input, 60); } diff --git a/src/shared/types/team.ts b/src/shared/types/team.ts index 909fd93b..63dbe188 100644 --- a/src/shared/types/team.ts +++ b/src/shared/types/team.ts @@ -353,6 +353,8 @@ export interface BoardTaskLogStreamRuntimeProjection { mode: 'attribution' | 'heuristic'; attributionRecordCount: number; projectedMessageCount: number; + boardMcpToolCount?: number; + nativeToolCount?: number; fallbackReason?: | 'no_attribution_records' | 'attribution_no_projected_messages' @@ -1312,7 +1314,7 @@ export interface TeamProvisioningProgress { warnings?: string[]; /** Provisioning CLI logs shown in the launch progress UI. */ cliLogsTail?: string; - /** Accumulated assistant text output during provisioning (for live preview). */ + /** Bounded launch trace plus assistant/runtime text output for the live preview. */ assistantOutput?: string; /** True once provisioning has written a readable config.json for this team. */ configReady?: boolean; diff --git a/src/shared/utils/codexModelCatalog.ts b/src/shared/utils/codexModelCatalog.ts new file mode 100644 index 00000000..d06201d0 --- /dev/null +++ b/src/shared/utils/codexModelCatalog.ts @@ -0,0 +1,19 @@ +import type { CliProviderModelCatalog } from '@shared/types'; + +export function isUsableCodexModelCatalog( + catalog: CliProviderModelCatalog | null | undefined +): catalog is CliProviderModelCatalog { + return ( + catalog?.schemaVersion === 1 && + catalog.providerId === 'codex' && + (catalog.source === 'app-server' || catalog.source === 'static-fallback') && + Array.isArray(catalog.models) && + catalog.models.some((model) => model.launchModel?.trim()) + ); +} + +export function isDynamicCodexModelCatalog(catalog: CliProviderModelCatalog): boolean { + return ( + catalog.source === 'app-server' && (catalog.status === 'ready' || catalog.status === 'stale') + ); +} diff --git a/src/shared/utils/contentSanitizer.ts b/src/shared/utils/contentSanitizer.ts index 3f71e5c3..b1b28e88 100644 --- a/src/shared/utils/contentSanitizer.ts +++ b/src/shared/utils/contentSanitizer.ts @@ -20,6 +20,10 @@ const NOISE_TAG_PATTERNS = [ /[\s\S]*?<\/local-command-caveat>/gi, /[\s\S]*?<\/system-reminder>/gi, /[\s\S]*?<\/task-notification>/gi, + /[\s\S]*?<\/opencode_runtime_identity>/gi, + /[\s\S]*?<\/opencode_app_message_delivery>/gi, + /[\s\S]*?<\/opencode_delivery_context>/gi, + /[\s\S]*?<\/opencode_delivery_retry>/gi, ]; /** @@ -27,6 +31,8 @@ const NOISE_TAG_PATTERNS = [ * task notifications. */ const TASK_OUTPUT_INSTRUCTION_PATTERN = / ?Read the output file to retrieve the result: [^\s]+/g; +const OPENCODE_INBOUND_APP_MESSAGE_PATTERN = + /\s*([\s\S]*?)\s*<\/opencode_inbound_app_message>/gi; export interface CommandOutputInfo { stream: 'stdout' | 'stderr'; @@ -121,6 +127,10 @@ export function sanitizeDisplayContent(content: string): string { for (const pattern of NOISE_TAG_PATTERNS) { sanitized = sanitized.replace(pattern, ''); } + sanitized = sanitized.replace( + OPENCODE_INBOUND_APP_MESSAGE_PATTERN, + (_match, innerContent: string | undefined) => innerContent?.trim() ?? '' + ); // Also remove any remaining command tags (in case of mixed content) sanitized = sanitized @@ -131,7 +141,7 @@ export function sanitizeDisplayContent(content: string): string { // Remove follow-up instructions that only make sense in raw XML form. sanitized = sanitized.replace(TASK_OUTPUT_INSTRUCTION_PATTERN, ''); - return sanitized.trim(); + return sanitized.replace(/\n{3,}/g, '\n\n').trim(); } /** diff --git a/src/types/agent-teams-controller.d.ts b/src/types/agent-teams-controller.d.ts index 13c816f0..77c209bb 100644 --- a/src/types/agent-teams-controller.d.ts +++ b/src/types/agent-teams-controller.d.ts @@ -2,6 +2,7 @@ declare module 'agent-teams-controller' { export interface ControllerContextOptions { teamName: string; claudeDir?: string; + allowUserMessageSender?: boolean; } export interface ControllerTaskApi { diff --git a/teams/definitely-missing-team/inboxes/user.json b/teams/definitely-missing-team/inboxes/user.json deleted file mode 100644 index ef40758f..00000000 --- a/teams/definitely-missing-team/inboxes/user.json +++ /dev/null @@ -1,11 +0,0 @@ -[ - { - "from": "nobody", - "to": "user", - "text": "plainprobe", - "timestamp": "2026-04-23T17:45:03.432Z", - "read": false, - "summary": "plainprobe", - "messageId": "a3ed3161-c883-4a6d-aff1-bc64e5eb547f" - } -] \ No newline at end of file diff --git a/test/features/codex-account/main/createCodexAccountFeature.test.ts b/test/features/codex-account/main/createCodexAccountFeature.test.ts index 0d0b847b..5ea072af 100644 --- a/test/features/codex-account/main/createCodexAccountFeature.test.ts +++ b/test/features/codex-account/main/createCodexAccountFeature.test.ts @@ -269,6 +269,10 @@ describe('createCodexAccountFeature', () => { source: 'environment', sourceLabel: 'Detected from OPENAI_API_KEY', }, + runtimeContext: { + binaryPath: '/usr/local/bin/codex', + codexHome: '/Users/test/.codex', + }, launchAllowed: true, launchReadinessState: 'ready_both', }); @@ -315,6 +319,10 @@ describe('createCodexAccountFeature', () => { type: 'chatgpt', email: 'user@example.com', }); + expect(degradedSnapshot.runtimeContext).toEqual({ + binaryPath: '/usr/local/bin/codex', + codexHome: '/Users/test/.codex', + }); expect(degradedSnapshot.launchAllowed).toBe(true); expect(logger.warn).not.toHaveBeenCalledWith( expect.stringContaining('false logout'), diff --git a/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts new file mode 100644 index 00000000..e33539f9 --- /dev/null +++ b/test/features/recent-projects/main/adapters/output/CodexSessionFileRecentProjectsSourceAdapter.test.ts @@ -0,0 +1,258 @@ +import fs from 'node:fs/promises'; +import os from 'node:os'; +import path from 'node:path'; + +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; + +import { CodexSessionFileRecentProjectsSourceAdapter } from '@features/recent-projects/main/adapters/output/sources/CodexSessionFileRecentProjectsSourceAdapter'; + +import type { LoggerPort } from '@features/recent-projects/core/application/ports/LoggerPort'; +import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver'; + +function createLogger(): LoggerPort & { + info: ReturnType; + warn: ReturnType; + error: ReturnType; +} { + return { + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }; +} + +async function writeRollout( + filePath: string, + payload: { + cwd: string; + source?: string; + timestamp?: string; + branch?: string; + }, + mtime: Date +): Promise { + await fs.mkdir(path.dirname(filePath), { recursive: true }); + await fs.writeFile( + filePath, + `${JSON.stringify({ + timestamp: payload.timestamp ?? mtime.toISOString(), + type: 'session_meta', + payload: { + id: path.basename(filePath, '.jsonl'), + timestamp: payload.timestamp ?? mtime.toISOString(), + cwd: payload.cwd, + source: payload.source ?? 'cli', + git: payload.branch ? { branch: payload.branch } : undefined, + }, + })}\n${'x'.repeat(1024)}`, + 'utf8' + ); + await fs.utimes(filePath, mtime, mtime); +} + +describe('CodexSessionFileRecentProjectsSourceAdapter', () => { + let tempDir: string; + + beforeEach(async () => { + tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'codex-session-files-')); + }); + + afterEach(async () => { + await fs.rm(tempDir, { recursive: true, force: true }); + vi.restoreAllMocks(); + }); + + it('loads recent interactive Codex projects from session files', async () => { + const codexHome = path.join(tempDir, '.codex'); + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn().mockResolvedValue({ + id: 'repo:alpha', + name: 'alpha', + }), + } as unknown as RecentProjectIdentityResolver; + const updatedAt = new Date('2026-04-14T12:00:00.000Z'); + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-alpha.jsonl'), + { + cwd: '/Users/test/projects/alpha', + branch: 'main', + }, + updatedAt + ); + + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome, + }); + + await expect(adapter.list()).resolves.toEqual({ + candidates: [ + expect.objectContaining({ + identity: 'repo:alpha', + displayName: 'alpha', + primaryPath: '/Users/test/projects/alpha', + lastActivityAt: updatedAt.getTime(), + providerIds: ['codex'], + sourceKind: 'codex', + openTarget: { + type: 'synthetic-path', + path: '/Users/test/projects/alpha', + }, + branchName: 'main', + }), + ], + degraded: false, + }); + expect(identityResolver.resolve).toHaveBeenCalledWith('/Users/test/projects/alpha'); + }); + + it('deduplicates sessions by cwd and keeps the newest activity', async () => { + const codexHome = path.join(tempDir, '.codex'); + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn().mockResolvedValue(null), + } as unknown as RecentProjectIdentityResolver; + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '13', 'rollout-alpha-old.jsonl'), + { + cwd: '/Users/test/projects/alpha', + branch: 'old', + }, + new Date('2026-04-13T12:00:00.000Z') + ); + await writeRollout( + path.join(codexHome, 'archived_sessions', 'rollout-alpha-new.jsonl'), + { + cwd: '/Users/test/projects/alpha', + branch: 'new', + }, + new Date('2026-04-14T12:00:00.000Z') + ); + + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome, + }); + + const result = await adapter.list(); + + expect(result.candidates).toHaveLength(1); + expect(result.candidates[0]).toEqual( + expect.objectContaining({ + primaryPath: '/Users/test/projects/alpha', + lastActivityAt: Date.parse('2026-04-14T12:00:00.000Z'), + branchName: 'new', + }) + ); + expect(identityResolver.resolve).toHaveBeenCalledTimes(1); + }); + + it('keeps scanning past duplicate recent sessions to find more projects', async () => { + const codexHome = path.join(tempDir, '.codex'); + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn().mockResolvedValue(null), + } as unknown as RecentProjectIdentityResolver; + const baseTime = Date.parse('2026-04-14T12:00:00.000Z'); + + await Promise.all( + Array.from({ length: 130 }).map((_, index) => + writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', `rollout-alpha-${index}.jsonl`), + { + cwd: '/Users/test/projects/alpha', + branch: 'main', + }, + new Date(baseTime - index * 1000) + ) + ) + ); + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-beta.jsonl'), + { + cwd: '/Users/test/projects/beta', + branch: 'main', + }, + new Date(baseTime - 140_000) + ); + + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome, + }); + + const result = await adapter.list(); + + expect(result.candidates.map((candidate) => candidate.primaryPath)).toEqual([ + '/Users/test/projects/alpha', + '/Users/test/projects/beta', + ]); + }); + + it('skips non-interactive and ephemeral sessions', async () => { + const codexHome = path.join(tempDir, '.codex'); + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn(), + } as unknown as RecentProjectIdentityResolver; + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-background.jsonl'), + { + cwd: '/Users/test/projects/background', + source: 'background', + }, + new Date('2026-04-14T12:00:00.000Z') + ); + await writeRollout( + path.join(codexHome, 'sessions', '2026', '04', '14', 'rollout-temp.jsonl'), + { + cwd: '/private/var/folders/x/T/codex-agent-teams-appstyle-123', + source: 'cli', + }, + new Date('2026-04-14T12:01:00.000Z') + ); + + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome, + }); + + await expect(adapter.list()).resolves.toEqual({ + candidates: [], + degraded: false, + }); + expect(identityResolver.resolve).not.toHaveBeenCalled(); + }); + + it('returns an empty healthy result when Codex session folders are absent', async () => { + const logger = createLogger(); + const identityResolver = { + resolve: vi.fn(), + } as unknown as RecentProjectIdentityResolver; + const adapter = new CodexSessionFileRecentProjectsSourceAdapter({ + getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never, + getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never, + identityResolver, + logger, + codexHome: path.join(tempDir, 'missing-codex-home'), + }); + + await expect(adapter.list()).resolves.toEqual({ + candidates: [], + degraded: false, + }); + }); +}); diff --git a/test/main/ipc/teams.test.ts b/test/main/ipc/teams.test.ts index 7013b0c2..f081d6bb 100644 --- a/test/main/ipc/teams.test.ts +++ b/test/main/ipc/teams.test.ts @@ -19,7 +19,7 @@ import type { vi.mock('electron', () => ({ app: { getLocale: vi.fn(() => 'en'), getPath: vi.fn(() => '/tmp'), isPackaged: false }, Notification: Object.assign(vi.fn(), { isSupported: vi.fn(() => false) }), - BrowserWindow: { getAllWindows: vi.fn(() => []) }, + BrowserWindow: { fromWebContents: vi.fn(() => null), getAllWindows: vi.fn(() => []) }, })); // Keep this mock resilient to new exports (avoid drift). diff --git a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts index 0bbc6195..5c38cb3b 100644 --- a/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts +++ b/test/main/services/runtime/ClaudeMultimodelBridgeService.test.ts @@ -233,6 +233,108 @@ describe('ClaudeMultimodelBridgeService', () => { }); }); + it('loads all providers with parallel provider-scoped runtime status probes', async () => { + const providerPayloads = { + anthropic: { + supported: true, + authenticated: true, + authMethod: 'oauth_token', + verificationState: 'verified', + canLoginFromUi: true, + models: ['claude-sonnet-4-5'], + capabilities: { teamLaunch: true, oneShot: true }, + backend: { kind: 'anthropic', label: 'Anthropic' }, + }, + codex: { + supported: true, + authenticated: true, + authMethod: 'api_key', + verificationState: 'verified', + canLoginFromUi: false, + models: ['gpt-5-codex'], + capabilities: { teamLaunch: true, oneShot: true }, + backend: { kind: 'codex-native', label: 'Codex native' }, + }, + gemini: { + supported: true, + authenticated: false, + verificationState: 'unknown', + canLoginFromUi: true, + statusMessage: 'No Gemini runtime backend is ready', + models: ['gemini-2.5-pro'], + capabilities: { teamLaunch: true, oneShot: true }, + }, + opencode: { + supported: true, + authenticated: true, + authMethod: 'opencode_managed', + verificationState: 'verified', + canLoginFromUi: false, + models: ['openai/gpt-5.4-mini'], + capabilities: { teamLaunch: true, oneShot: false }, + backend: { kind: 'opencode-cli', label: 'OpenCode CLI' }, + }, + } as const; + + execCliMock.mockImplementation((_binaryPath, args) => { + const normalizedArgs = Array.isArray(args) ? args.join(' ') : ''; + const providerArgIndex = Array.isArray(args) ? args.indexOf('--provider') : -1; + const providerId = + providerArgIndex >= 0 && Array.isArray(args) + ? (args[providerArgIndex + 1] as keyof typeof providerPayloads) + : null; + + if ( + normalizedArgs.startsWith('runtime status --json --provider ') && + providerId && + providerPayloads[providerId] + ) { + return Promise.resolve({ + stdout: JSON.stringify({ + schemaVersion: 2, + providers: { + [providerId]: providerPayloads[providerId], + }, + }), + stderr: '', + exitCode: 0, + }); + } + + return Promise.reject(new Error(`Unexpected execCli call: ${normalizedArgs}`)); + }); + + const { ClaudeMultimodelBridgeService } = + await import('@main/services/runtime/ClaudeMultimodelBridgeService'); + const service = new ClaudeMultimodelBridgeService(); + const onUpdate = vi.fn(); + + const providers = await service.getProviderStatuses('/mock/agent_teams_orchestrator', onUpdate); + + expect(execCliMock).toHaveBeenCalledTimes(4); + expect(execCliMock.mock.calls.map((call) => call[1].join(' '))).toEqual( + expect.arrayContaining([ + 'runtime status --json --provider anthropic', + 'runtime status --json --provider codex', + 'runtime status --json --provider gemini', + 'runtime status --json --provider opencode', + ]) + ); + expect(providers.map((provider) => provider.providerId)).toEqual([ + 'anthropic', + 'codex', + 'gemini', + 'opencode', + ]); + expect(providers.find((provider) => provider.providerId === 'codex')).toMatchObject({ + authenticated: true, + models: ['gpt-5-codex'], + backend: { kind: 'codex-native' }, + }); + expect(onUpdate).toHaveBeenCalled(); + expect(onUpdate.mock.calls.at(-1)?.[0]).toEqual(providers); + }); + it('overrides provider auth status when provider-aware env reports a missing API key', async () => { buildProviderAwareCliEnvMock.mockResolvedValue({ env: { HOME: '/Users/tester' }, diff --git a/test/main/services/runtime/ProviderConnectionService.test.ts b/test/main/services/runtime/ProviderConnectionService.test.ts index 308029f5..2b801564 100644 --- a/test/main/services/runtime/ProviderConnectionService.test.ts +++ b/test/main/services/runtime/ProviderConnectionService.test.ts @@ -285,6 +285,122 @@ describe('ProviderConnectionService', () => { expect(result.CODEX_API_KEY).toBe('shell-openai-key'); }); + it('passes Codex runtime context while clearing API keys for ChatGPT launches', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'chatgpt', + effectiveAuthMode: 'chatgpt', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_chatgpt', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: { + type: 'chatgpt', + email: 'user@example.com', + planType: 'pro', + }, + apiKey: { + available: false, + source: null, + sourceLabel: null, + }, + requiresOpenaiAuth: false, + runtimeContext: { + binaryPath: '/opt/codex/bin/codex', + codexHome: '/Users/tester/.codex-custom', + }, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + const result = await service.applyConfiguredConnectionEnv( + { + OPENAI_API_KEY: 'ambient-openai-key', + CODEX_API_KEY: 'ambient-codex-key', + }, + 'codex' + ); + + expect(result.OPENAI_API_KEY).toBeUndefined(); + expect(result.CODEX_API_KEY).toBeUndefined(); + expect(result.CODEX_CLI_PATH).toBe('/opt/codex/bin/codex'); + expect(result.CODEX_HOME).toBe('/Users/tester/.codex-custom'); + }); + + it('keeps Codex runtime context when API-key mode mirrors credentials', async () => { + const lookupPreferred = vi.fn().mockResolvedValue({ + envVarName: 'OPENAI_API_KEY', + value: 'stored-openai-key', + }); + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + + const service = new ProviderConnectionService( + { + lookupPreferred, + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + + service.setCodexAccountFeature({ + getSnapshot: vi.fn().mockResolvedValue({ + preferredAuthMode: 'api_key', + effectiveAuthMode: 'api_key', + launchAllowed: true, + launchIssueMessage: null, + launchReadinessState: 'ready_api_key', + appServerState: 'healthy', + appServerStatusMessage: null, + managedAccount: null, + apiKey: { + available: true, + source: 'stored', + sourceLabel: 'Stored in app', + }, + requiresOpenaiAuth: false, + runtimeContext: { + binaryPath: '/opt/codex/bin/codex.cmd', + codexHome: 'C:\\Users\\tester\\.codex', + }, + login: { + status: 'idle', + error: null, + startedAt: null, + }, + rateLimits: null, + updatedAt: '2026-04-20T00:00:00.000Z', + }), + } as never); + + const result = await service.applyConfiguredConnectionEnv({}, 'codex'); + + expect(result.OPENAI_API_KEY).toBe('stored-openai-key'); + expect(result.CODEX_API_KEY).toBe('stored-openai-key'); + expect(result.CODEX_CLI_PATH).toBe('/opt/codex/bin/codex.cmd'); + expect(result.CODEX_HOME).toBe('C:\\Users\\tester\\.codex'); + }); + it('accepts CODEX_API_KEY as the native external credential source for Codex', async () => { getCachedShellEnvMock.mockReturnValue({ CODEX_API_KEY: 'native-key', @@ -674,4 +790,108 @@ describe('ProviderConnectionService', () => { expect(args).toEqual(['-c', 'forced_login_method="api"']); }); + + it('prefers the orchestrator Codex model catalog over the legacy direct app-server fallback', async () => { + const { ProviderConnectionService } = + await import('@main/services/runtime/ProviderConnectionService'); + const directCatalog = vi.fn().mockResolvedValue({ + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-28T00:00:00.000Z', + staleAt: '2026-04-28T00:10:00.000Z', + defaultModelId: 'gpt-5.4-mini', + defaultLaunchModel: 'gpt-5.4-mini', + models: [], + diagnostics: { + configReadState: 'ready', + appServerState: 'healthy', + }, + }); + + const service = new ProviderConnectionService( + { + lookupPreferred: vi.fn().mockResolvedValue(null), + } as never, + { + getConfig: () => createConfig('auto'), + } as never + ); + service.setCodexModelCatalogFeature({ getCatalog: directCatalog } as never); + + const enriched = await service.enrichProviderStatus({ + providerId: 'codex', + displayName: 'Codex', + supported: true, + authenticated: true, + authMethod: 'chatgpt', + verificationState: 'verified', + models: ['gpt-5.4'], + modelCatalog: { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-28T00:00:00.000Z', + staleAt: '2026-04-28T00:10:00.000Z', + defaultModelId: 'gpt-5.4', + defaultLaunchModel: 'gpt-5.4', + models: [ + { + id: 'gpt-5.4', + launchModel: 'gpt-5.4', + displayName: 'GPT-5.4', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'medium', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: true, + upgrade: false, + source: 'app-server', + }, + { + id: 'gpt-5.5', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'skipped', + appServerState: 'healthy', + }, + }, + runtimeCapabilities: { + modelCatalog: { dynamic: true, source: 'app-server' }, + }, + canLoginFromUi: false, + capabilities: { + teamLaunch: true, + oneShot: true, + extensions: { + plugins: { status: 'unsupported', ownership: 'shared' }, + mcp: { status: 'supported', ownership: 'shared' }, + skills: { status: 'supported', ownership: 'shared' }, + apiKeys: { status: 'supported', ownership: 'shared' }, + }, + }, + }); + + expect(directCatalog).not.toHaveBeenCalled(); + expect(enriched.models).toEqual(['gpt-5.4', 'gpt-5.5']); + expect(enriched.modelCatalog?.defaultLaunchModel).toBe('gpt-5.4'); + expect(enriched.runtimeCapabilities?.modelCatalog).toEqual({ + dynamic: true, + source: 'app-server', + }); + }); }); diff --git a/test/main/services/runtime/cliSettingsArgs.test.ts b/test/main/services/runtime/cliSettingsArgs.test.ts new file mode 100644 index 00000000..742d14a2 --- /dev/null +++ b/test/main/services/runtime/cliSettingsArgs.test.ts @@ -0,0 +1,67 @@ +import { describe, expect, it } from 'vitest'; + +import { mergeJsonSettingsArgs } from '../../../../src/main/services/runtime/cliSettingsArgs'; + +function getSettingsValues(args: string[]): string[] { + const values: string[] = []; + for (let i = 0; i < args.length; i += 1) { + if (args[i] === '--settings' && typeof args[i + 1] === 'string') { + values.push(args[i + 1]); + i += 1; + } + } + return values; +} + +describe('mergeJsonSettingsArgs', () => { + it('merges app and provider JSON settings into a single settings argument', () => { + const merged = mergeJsonSettingsArgs([ + '--settings', + '{"fastMode":false}', + '--model', + 'gpt-5.4', + '--settings', + '{"codex":{"forced_login_method":"chatgpt"}}', + ]); + + expect(merged).toEqual([ + '--settings', + '{"fastMode":false,"codex":{"forced_login_method":"chatgpt"}}', + '--model', + 'gpt-5.4', + ]); + }); + + it('deep merges nested JSON settings and lets later values win', () => { + const merged = mergeJsonSettingsArgs([ + '--settings', + '{"codex":{"forced_login_method":"api","existing":true}}', + '--settings', + '{"codex":{"forced_login_method":"chatgpt"}}', + ]); + + expect(JSON.parse(getSettingsValues(merged)[0] ?? '{}')).toEqual({ + codex: { + forced_login_method: 'chatgpt', + existing: true, + }, + }); + }); + + it('preserves non-JSON settings values while merging JSON settings', () => { + const merged = mergeJsonSettingsArgs([ + '--settings', + '/tmp/settings.json', + '--settings={"fastMode":false}', + '--settings', + '{"codex":{"forced_login_method":"chatgpt"}}', + ]); + + expect(merged).toEqual([ + '--settings', + '/tmp/settings.json', + '--settings', + '{"fastMode":false,"codex":{"forced_login_method":"chatgpt"}}', + ]); + }); +}); diff --git a/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts b/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts index edc2faae..40c7fdd5 100644 --- a/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts +++ b/test/main/services/team/BoardTaskActivityEntryBuilder.test.ts @@ -300,6 +300,51 @@ describe('BoardTaskActivityEntryBuilder', () => { expect(entries[0]?.actor.role).toBe('unknown'); }); + it('keeps named main-session teammates as members instead of forcing lead role', () => { + const taskA = makeTask({ + id: '123e4567-e89b-12d3-a456-426614174000', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + }); + + const messages: RawTaskActivityMessage[] = [ + { + filePath: '/tmp/main-member.jsonl', + uuid: 'msg-main-member', + timestamp: '2026-04-12T10:00:00.000Z', + sessionId: 'session-1', + agentName: 'tom', + isSidechain: false, + sourceOrder: 1, + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: 'abcd1234', refKind: 'display', canonicalId: taskA.id }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'same_task' }, + }, + ], + boardTaskToolActions: [], + }, + ]; + + const entries = new BoardTaskActivityEntryBuilder().buildForTask({ + teamName: 'demo', + targetTask: taskA, + tasks: [taskA], + messages, + }); + + expect(entries).toHaveLength(1); + expect(entries[0]?.actor).toMatchObject({ + memberName: 'tom', + role: 'member', + isSidechain: false, + }); + }); + it('never joins action payloads onto execution rows', () => { const taskA = makeTask({ id: '123e4567-e89b-12d3-a456-426614174000', diff --git a/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts b/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts index 0ae91ecd..d3ae9540 100644 --- a/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts +++ b/test/main/services/team/BoardTaskActivityTranscriptReader.test.ts @@ -94,4 +94,57 @@ describe('BoardTaskActivityTranscriptReader', () => { ], }); }); + + it('inherits stable session actor context for task-linked Codex projection rows', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'activity-transcript-reader-actor-')); + tempDirs.push(tempDir); + + const filePath = path.join(tempDir, 'codex-session.jsonl'); + await fs.writeFile( + filePath, + [ + JSON.stringify({ + uuid: 'session-context', + sessionId: 'session-codex', + timestamp: '2026-04-20T12:00:00.000Z', + agentName: 'tom', + isSidechain: false, + message: { role: 'assistant', content: 'Starting task' }, + }), + JSON.stringify({ + uuid: 'linked-without-agent-name', + sessionId: 'session-codex', + timestamp: '2026-04-20T12:01:00.000Z', + boardTaskLinks: [ + { + schemaVersion: 1, + task: { ref: '12345678', refKind: 'display', canonicalId: 'task-a' }, + targetRole: 'subject', + linkKind: 'board_action', + actorContext: { relation: 'same_task' }, + toolUseId: 'toolu_task_comment', + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'toolu_task_comment', + canonicalToolName: 'task_add_comment', + }, + ], + }), + ].join('\n'), + 'utf8' + ); + + const rows = await new BoardTaskActivityTranscriptReader().readFiles([filePath]); + + expect(rows).toHaveLength(1); + expect(rows[0]).toMatchObject({ + uuid: 'linked-without-agent-name', + sessionId: 'session-codex', + agentName: 'tom', + isSidechain: false, + }); + }); }); diff --git a/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts b/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts index a9c0b1a9..cf0b585a 100644 --- a/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts +++ b/test/main/services/team/BoardTaskExactLogDetailSelector.test.ts @@ -166,6 +166,134 @@ describe('BoardTaskExactLogDetailSelector', () => { expect(detail?.filteredMessages[2]?.sourceToolUseID).toBe('tool-1'); }); + it('scopes repeated tool use ids to the explicit tool-result parent assistant', () => { + const record = { + ...makeRecord(), + id: 'record-reused-tool-id', + action: { + canonicalToolName: 'task_add_comment', + toolUseId: 'item_24', + category: 'comment' as const, + }, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'user-target', + toolUseId: 'item_24', + sourceOrder: 1, + }, + } satisfies BoardTaskActivityRecord; + const candidate: BoardTaskExactLogBundleCandidate = { + id: 'tool:/tmp/task.jsonl:item_24', + timestamp: '2026-04-12T16:00:00.000Z', + actor: record.actor, + source: { + filePath: '/tmp/task.jsonl', + messageUuid: 'user-target', + toolUseId: 'item_24', + sourceOrder: 1, + }, + records: [record], + anchor: { + kind: 'tool', + filePath: '/tmp/task.jsonl', + messageUuid: 'user-target', + toolUseId: 'item_24', + }, + actionLabel: 'Added a comment', + actionCategory: 'comment', + canonicalToolName: 'task_add_comment', + linkKinds: ['board_action'], + targetRoles: ['subject'], + canLoadDetail: true, + sourceGeneration: 'gen-reused-tool-id', + }; + const parsedMessagesByFile = new Map([ + [ + '/tmp/task.jsonl', + [ + { + uuid: 'assistant-target', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:00:00.000Z'), + role: 'assistant', + content: [ + { type: 'tool_use', id: 'item_24', name: 'task_add_comment', input: { taskId: 'task-a' } } as never, + ], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-target', + parentUuid: 'assistant-target', + type: 'user', + timestamp: new Date('2026-04-12T16:00:01.000Z'), + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'item_24', content: 'target result' } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'item_24', + sourceToolAssistantUUID: 'assistant-target', + toolUseResult: { toolUseId: 'item_24', content: 'target result' }, + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'assistant-other', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:10:00.000Z'), + role: 'assistant', + content: [ + { type: 'tool_use', id: 'item_24', name: 'task_complete', input: { taskId: 'other-task' } } as never, + ], + toolCalls: [], + toolResults: [], + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-other', + parentUuid: 'assistant-other', + type: 'user', + timestamp: new Date('2026-04-12T16:10:01.000Z'), + role: 'user', + content: [ + { type: 'tool_result', tool_use_id: 'item_24', content: 'other result' } as never, + ], + toolCalls: [], + toolResults: [], + sourceToolUseID: 'item_24', + sourceToolAssistantUUID: 'assistant-other', + toolUseResult: { toolUseId: 'item_24', content: 'other result' }, + isSidechain: true, + isMeta: false, + isCompactSummary: false, + }, + ], + ], + ]); + + const detail = new BoardTaskExactLogDetailSelector().selectDetail({ + candidate, + records: [record], + parsedMessagesByFile, + }); + + expect(detail).not.toBeNull(); + expect(detail?.filteredMessages.map((message) => message.uuid)).toEqual([ + 'assistant-target', + 'user-target', + ]); + }); + it('drops stale derived tool metadata when a message-linked row survives filtering', () => { const record = { ...makeRecord(), diff --git a/test/main/services/team/BoardTaskExactLogStrictParser.test.ts b/test/main/services/team/BoardTaskExactLogStrictParser.test.ts index 0b4054c7..7303ec71 100644 --- a/test/main/services/team/BoardTaskExactLogStrictParser.test.ts +++ b/test/main/services/team/BoardTaskExactLogStrictParser.test.ts @@ -142,4 +142,63 @@ describe('BoardTaskExactLogStrictParser', () => { }, ]); }); + + it('inherits stable session actor context for Codex-native rows without agentName', async () => { + const tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'exact-log-parser-actor-')); + tempDirs.push(tempDir); + + const filePath = path.join(tempDir, 'native-session.jsonl'); + await fs.writeFile( + filePath, + [ + JSON.stringify({ + parentUuid: null, + isSidechain: false, + userType: 'external', + cwd: '/tmp/project', + sessionId: 'session-codex', + version: '1.0.0', + gitBranch: 'main', + agentName: 'tom', + type: 'system', + uuid: 'session-context', + timestamp: '2026-04-19T10:00:00.000Z', + subtype: 'init', + level: 'info', + isMeta: false, + content: 'started', + }), + JSON.stringify({ + parentUuid: 'session-context', + userType: 'external', + cwd: '/tmp/project', + sessionId: 'session-codex', + version: '1.0.0', + gitBranch: 'main', + type: 'assistant', + uuid: 'assistant-without-agent', + timestamp: '2026-04-19T10:00:01.000Z', + requestId: 'req-1', + message: { + role: 'assistant', + id: 'msg-1', + type: 'message', + model: 'codex', + content: [{ type: 'text', text: 'working' }], + stop_reason: null, + stop_sequence: null, + usage: { input_tokens: 1, output_tokens: 1 }, + }, + }), + ].join('\n'), + 'utf8' + ); + + const parsed = await new BoardTaskExactLogStrictParser().parseFiles([filePath]); + + expect(parsed.get(filePath)?.map((message) => [message.uuid, message.agentName])).toEqual([ + ['session-context', 'tom'], + ['assistant-without-agent', 'tom'], + ]); + }); }); diff --git a/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts b/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts index 88b08357..3e97c5af 100644 --- a/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts +++ b/test/main/services/team/BoardTaskLogDiagnosticsService.test.ts @@ -354,4 +354,115 @@ describe('BoardTaskLogDiagnosticsService', () => { expect(report.stream.visibleToolNames).toContain('mcp__agent-teams__task_complete'); expect(report.diagnosis.join(' ')).not.toContain('Only board MCP actions are explicit'); }); + + it('ignores non-record toolUseResult values when checking empty stream payloads', async () => { + const task = createTask(); + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + listTranscriptFiles: async () => [] as string[], + }; + const recordSource = { + getTaskRecords: async () => [], + }; + const strictParser = { + parseFiles: async () => new Map(), + }; + const streamService = { + getTaskLogStream: async () => ({ + participants: [], + defaultFilter: 'all' as const, + segments: [ + { + id: 'segment-1', + participantKey: 'member:tom', + actor: { + memberName: 'tom', + role: 'member' as const, + sessionId: 'session-tom', + isSidechain: false, + }, + startTimestamp: '2026-04-12T15:36:00.000Z', + endTimestamp: '2026-04-12T15:36:00.000Z', + chunks: [ + { + id: 'chunk-1', + rawMessages: [ + { + uuid: 'assistant-1', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T15:36:00.000Z'), + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-1', + name: 'mcp__agent-teams__task_add_comment', + input: {}, + }, + ], + toolCalls: [ + { + id: 'tool-1', + name: 'mcp__agent-teams__task_add_comment', + input: {}, + isTask: false, + }, + ], + toolResults: [], + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-1', + parentUuid: 'assistant-1', + type: 'user', + timestamp: new Date('2026-04-12T15:36:01.000Z'), + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-1', + content: 'validation failed', + is_error: true, + }, + ], + toolCalls: [], + toolResults: [ + { + toolUseId: 'tool-1', + content: 'validation failed', + isError: true, + }, + ], + sourceToolUseID: 'tool-1', + toolUseResult: new Error('validation failed'), + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + ], + }, + ], + }, + ], + }), + }; + const diagnosticsService = new BoardTaskLogDiagnosticsService( + taskReader as never, + transcriptSourceLocator as never, + recordSource as never, + strictParser as never, + streamService as never, + ); + + const report = await diagnosticsService.diagnose(TEAM_NAME, TASK_ID); + + expect(report.stream.emptyPayloadExamples).toEqual([]); + expect(report.stream.visibleToolNames).toEqual(['mcp__agent-teams__task_add_comment']); + }); }); diff --git a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts index 9d1f2790..6b125d42 100644 --- a/test/main/services/team/BoardTaskLogStreamIntegration.test.ts +++ b/test/main/services/team/BoardTaskLogStreamIntegration.test.ts @@ -947,6 +947,153 @@ describe('BoardTaskLogStreamService integration', () => { }); }); + it('unwraps Agent Teams response envelopes before rendering task comment output', async () => { + const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-agent-teams-envelope-')); + tempDirs.push(dir); + const transcriptPath = path.join(dir, 'session.jsonl'); + const task = createTask({ + owner: 'bob', + workIntervals: [ + { + startedAt: '2026-04-27T20:05:00.000Z', + completedAt: '2026-04-27T20:10:00.000Z', + }, + ], + }); + const payload = { + agent_teams_task_add_comment_response: { + comment: { + attachments: [], + author: 'bob', + createdAt: '2026-04-27T20:05:44.248Z', + id: '40203f1f-44e2-45e0-b6a8-2b812fb7ac12', + text: 'Π‘ΠΎΠ·Π΄Π°Π½Π° ΠΏΠ°ΠΏΠΊΠ° `944` ΠΈ Ρ„Π°ΠΉΠ» `calculator.js`.', + }, + taskId: TASK_ID, + teamName: TEAM_NAME, + }, + }; + + const lines = [ + createAssistantEntry({ + uuid: 'a-comment', + timestamp: '2026-04-27T20:05:44.000Z', + requestId: 'req-comment', + agentName: 'bob', + content: [ + { + type: 'tool_use', + id: 'call-comment', + name: 'agent-teams_task_add_comment', + input: { + teamName: TEAM_NAME, + taskId: TASK_ID, + from: 'bob', + text: 'Π‘ΠΎΠ·Π΄Π°Π½Π° ΠΏΠ°ΠΏΠΊΠ° `944` ΠΈ Ρ„Π°ΠΉΠ» `calculator.js`.', + }, + }, + ], + }), + createUserEntry({ + uuid: 'u-comment', + timestamp: '2026-04-27T20:05:44.248Z', + sourceToolAssistantUUID: 'a-comment', + agentName: 'bob', + content: [ + { + type: 'tool_result', + tool_use_id: 'call-comment', + content: JSON.stringify(payload), + }, + ], + boardTaskLinks: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + task: { + ref: TASK_ID, + refKind: 'canonical', + canonicalId: TASK_ID, + }, + targetRole: 'subject', + linkKind: 'board_action', + taskArgumentSlot: 'taskId', + actorContext: { + relation: 'owner', + }, + }, + ], + boardTaskToolActions: [ + { + schemaVersion: 1, + toolUseId: 'call-comment', + canonicalToolName: 'task_add_comment', + }, + ], + toolUseResult: { + toolUseId: 'call-comment', + content: JSON.stringify(payload), + }, + }), + ]; + + await writeFile( + transcriptPath, + `${lines.map((line) => JSON.stringify(line)).join('\n')}\n`, + 'utf8', + ); + + const recordSource = { + getTaskRecords: async () => buildRecordsFromTranscript(transcriptPath, task), + }; + const taskReader = { + getTasks: async () => [task], + getDeletedTasks: async () => [] as TeamTask[], + }; + const transcriptSourceLocator = { + getContext: async () => + ({ + transcriptFiles: [transcriptPath], + config: { + members: [{ name: 'bob', agentType: 'developer' }], + }, + }) as never, + }; + + const service = new BoardTaskLogStreamService( + recordSource as never, + undefined as never, + undefined as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + ); + const response = await service.getTaskLogStream(TEAM_NAME, task.id); + const rawMessages = flattenRawMessages(response); + const commentResult = rawMessages.find((message) => message.uuid === 'u-comment'); + const semanticToolResult = response.segments + .flatMap((segment) => segment.chunks) + .flatMap((chunk) => ('semanticSteps' in chunk ? (chunk.semanticSteps ?? []) : [])) + .find((step) => step.type === 'tool_result' && step.id === 'call-comment'); + + expect(commentResult?.toolResults).toEqual([ + { + toolUseId: 'call-comment', + content: 'Π‘ΠΎΠ·Π΄Π°Π½Π° ΠΏΠ°ΠΏΠΊΠ° `944` ΠΈ Ρ„Π°ΠΉΠ» `calculator.js`.', + isError: false, + }, + ]); + expect(semanticToolResult).toMatchObject({ + id: 'call-comment', + type: 'tool_result', + content: expect.objectContaining({ + toolResultContent: 'Π‘ΠΎΠ·Π΄Π°Π½Π° ΠΏΠ°ΠΏΠΊΠ° `944` ΠΈ Ρ„Π°ΠΉΠ» `calculator.js`.', + }), + }); + expect(JSON.stringify(response)).not.toContain('agent_teams_task_add_comment_response'); + }); + it('reads a real-format transcript fixture and surfaces fallback worker logs for the task owner only', async () => { const dir = await mkdtemp(path.join(tmpdir(), 'task-log-stream-real-fixture-')); tempDirs.push(dir); diff --git a/test/main/services/team/BoardTaskLogStreamService.test.ts b/test/main/services/team/BoardTaskLogStreamService.test.ts index 0b61c396..3863ff04 100644 --- a/test/main/services/team/BoardTaskLogStreamService.test.ts +++ b/test/main/services/team/BoardTaskLogStreamService.test.ts @@ -163,6 +163,170 @@ describe('BoardTaskLogStreamService', () => { expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledTimes(1); }); + it('merges OpenCode runtime stream when board transcript slices mask member execution', async () => { + const lead = { + role: 'lead' as const, + sessionId: 'session-lead', + isSidechain: false, + }; + const candidate = { + ...makeCandidate('c1', '2026-04-12T16:00:00.000Z', lead, 'tool-board'), + actionCategory: 'comment' as const, + canonicalToolName: 'task_add_comment', + }; + const runtimeFallbackSource = { + getTaskLogStream: vi.fn(async () => ({ + participants: [ + { + key: 'member:jack', + label: 'jack', + role: 'member' as const, + isLead: false, + isSidechain: true, + }, + ], + defaultFilter: 'member:jack', + segments: [ + { + id: 'opencode:demo:task-a:jack', + participantKey: 'member:jack', + actor: { + memberName: 'jack', + role: 'member' as const, + sessionId: 'session-opencode', + isSidechain: true, + }, + startTimestamp: '2026-04-12T16:01:00.000Z', + endTimestamp: '2026-04-12T16:02:00.000Z', + chunks: [{ id: 'chunk-bash' }], + }, + ], + source: 'opencode_runtime_fallback' as const, + runtimeProjection: { + provider: 'opencode' as const, + mode: 'heuristic' as const, + attributionRecordCount: 0, + projectedMessageCount: 2, + fallbackReason: 'task_tool_markers' as const, + }, + })), + }; + const recordSource = { + getTaskRecords: vi.fn(async () => candidate.records), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [candidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: lead, + source: candidate.source, + records: candidate.records, + filteredMessages: [makeMessage('c1', '2026-04-12T16:00:00.000Z', 'board update')], + })), + }; + const taskReader = { + getTasks: vi.fn(async () => [{ id: 'task-a', owner: 'jack' }]), + getDeletedTasks: vi.fn(async () => []), + }; + const membersMetaStore = { + getMembers: vi.fn(async () => [{ name: 'jack', providerId: 'opencode' }]), + }; + const configReader = { + getConfig: vi.fn(async () => null), + }; + const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); + + const service = new BoardTaskLogStreamService( + recordSource as never, + summarySelector as never, + strictParser as never, + detailSelector as never, + { buildBundleChunks } as never, + taskReader as never, + undefined as never, + runtimeFallbackSource as never, + membersMetaStore as never, + configReader as never + ); + + const response = await service.getTaskLogStream('demo', 'task-a'); + + expect(runtimeFallbackSource.getTaskLogStream).toHaveBeenCalledWith('demo', 'task-a'); + expect(response.defaultFilter).toBe('member:jack'); + expect(response.participants.map((participant) => participant.key)).toEqual([ + 'member:jack', + 'lead', + ]); + expect(response.segments.map((segment) => segment.id)).toEqual([ + 'lead:c1:c1', + 'opencode:demo:task-a:jack', + ]); + expect(response.runtimeProjection).toMatchObject({ + provider: 'opencode', + projectedMessageCount: 2, + }); + }); + + it('does not probe OpenCode runtime for non-OpenCode task owners', async () => { + const lead = { + role: 'lead' as const, + sessionId: 'session-lead', + isSidechain: false, + }; + const candidate = makeCandidate('c1', '2026-04-12T16:00:00.000Z', lead, 'tool-board'); + const runtimeFallbackSource = { + getTaskLogStream: vi.fn(async () => { + throw new Error('should not be called'); + }), + }; + const service = new BoardTaskLogStreamService( + { + getTaskRecords: vi.fn(async () => candidate.records), + } as never, + { + selectSummaries: vi.fn(() => [candidate]), + } as never, + { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + } as never, + { + selectDetail: vi.fn(() => ({ + id: 'c1', + timestamp: '2026-04-12T16:00:00.000Z', + actor: lead, + source: candidate.source, + records: candidate.records, + filteredMessages: [makeMessage('c1', '2026-04-12T16:00:00.000Z', 'board update')], + })), + } as never, + { + buildBundleChunks: vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]), + } as never, + { + getTasks: vi.fn(async () => [{ id: 'task-a', owner: 'alice' }]), + getDeletedTasks: vi.fn(async () => []), + } as never, + undefined as never, + runtimeFallbackSource as never, + { + getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'codex' }]), + } as never, + { + getConfig: vi.fn(async () => null), + } as never + ); + + await service.getTaskLogStream('demo', 'task-a'); + + expect(runtimeFallbackSource.getTaskLogStream).not.toHaveBeenCalled(); + }); + it('groups contiguous slices into participant segments and excludes lead slices when member slices exist', async () => { const tom = { memberName: 'tom', @@ -708,6 +872,259 @@ describe('BoardTaskLogStreamService', () => { expect(mergedMessages.map((message) => message.uuid)).toEqual(['c2']); }); + it('does not use read-only task readers as inferred execution participants', async () => { + const alice = { + memberName: 'alice', + role: 'member' as const, + sessionId: 'session-alice', + isSidechain: false, + }; + const readRecord = { + ...makeRecord('alice-read', '2026-04-12T16:00:00.000Z', alice, 'tool-read'), + action: { + canonicalToolName: 'task_get', + toolUseId: 'tool-read', + category: 'read' as const, + }, + }; + const readCandidate: BoardTaskExactLogBundleCandidate = { + ...makeCandidate('alice-read', '2026-04-12T16:00:00.000Z', alice, 'tool-read'), + records: [readRecord], + actionCategory: 'read', + canonicalToolName: 'task_get', + }; + const aliceRuntimeMessage: ParsedMessage = { + uuid: 'alice-bash', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-12T16:02:00.000Z'), + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-bash', + name: 'Bash', + input: { command: 'git diff' }, + } as never, + ], + toolCalls: [ + { + id: 'tool-bash', + name: 'Bash', + input: { command: 'git diff' }, + isTask: false, + }, + ], + toolResults: [], + sessionId: 'session-alice', + agentName: 'alice', + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }; + + const recordSource = { + getTaskRecords: vi.fn(async () => [readRecord]), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [readCandidate]), + }; + const strictParser = { + parseFiles: vi.fn(async (filePaths: string[]) => + new Map( + filePaths.map((filePath) => [ + filePath, + filePath === '/tmp/alice.jsonl' ? [aliceRuntimeMessage] : [], + ]) + ) + ), + }; + const detailSelector = { + selectDetail: vi.fn(() => ({ + id: 'alice-read', + timestamp: '2026-04-12T16:00:00.000Z', + actor: alice, + source: readCandidate.source, + records: [readRecord], + filteredMessages: [makeMessage('alice-read-detail', '2026-04-12T16:00:00.000Z', 'read')], + })), + }; + const taskReader = { + getTasks: vi.fn(async () => [ + { + id: 'task-a', + displayId: 'abcd1234', + owner: 'tom', + status: 'in_progress', + createdAt: '2026-04-12T15:59:00.000Z', + updatedAt: '2026-04-12T16:05:00.000Z', + }, + ]), + getDeletedTasks: vi.fn(async () => []), + }; + const transcriptSourceLocator = { + getContext: vi.fn(async () => ({ + transcriptFiles: ['/tmp/task.jsonl', '/tmp/alice.jsonl'], + config: { members: [{ name: 'team-lead', agentType: 'team-lead' }] }, + })), + }; + const runtimeFallbackSource = { + getTaskLogStream: vi.fn(async () => null), + }; + const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); + + const service = new BoardTaskLogStreamService( + recordSource as never, + summarySelector as never, + strictParser as never, + detailSelector as never, + { buildBundleChunks } as never, + taskReader as never, + transcriptSourceLocator as never, + runtimeFallbackSource as never, + { getMembers: vi.fn(async () => [{ name: 'tom', providerId: 'codex' }]) } as never, + { getConfig: vi.fn(async () => null) } as never + ); + + const response = await service.getTaskLogStream('demo', 'task-a'); + + expect(response.segments).toHaveLength(1); + expect(response.segments[0]?.participantKey).toBe('member:alice'); + const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; + expect(mergedMessages.map((message) => message.uuid)).toEqual(['alice-read-detail']); + }); + + it('does not recover task_get logs from nested task refs in result payloads', async () => { + const taskReader = { + getTasks: vi.fn(async () => [ + { + id: 'task-a', + displayId: 'abcd1234', + owner: 'tom', + status: 'completed', + createdAt: '2026-04-12T16:00:00.000Z', + updatedAt: '2026-04-12T16:05:00.000Z', + }, + ]), + getDeletedTasks: vi.fn(async () => []), + }; + const transcriptSourceLocator = { + getContext: vi.fn(async () => ({ + transcriptFiles: ['/tmp/lead.jsonl'], + config: { + members: [{ name: 'team-lead', agentType: 'team-lead' }], + }, + })), + }; + const strictParser = { + parseFiles: vi.fn(async () => + new Map([ + [ + '/tmp/lead.jsonl', + [ + { + uuid: 'assistant-task-get', + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date('2026-04-12T16:01:00.000Z'), + role: 'assistant', + content: [ + { + type: 'tool_use', + id: 'tool-task-get', + name: 'task_get', + input: { teamName: 'demo', taskId: 'parent-task' }, + } as never, + ], + toolCalls: [ + { + id: 'tool-task-get', + name: 'task_get', + input: { teamName: 'demo', taskId: 'parent-task' }, + isTask: false, + }, + ], + toolResults: [], + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: 'user-task-get-result', + parentUuid: 'assistant-task-get', + type: 'user' as const, + timestamp: new Date('2026-04-12T16:01:01.000Z'), + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'tool-task-get', + content: JSON.stringify({ + id: 'parent-task', + displayId: 'parent', + blockedBy: ['task-a'], + }), + } as never, + ], + toolCalls: [], + toolResults: [ + { + toolUseId: 'tool-task-get', + content: JSON.stringify({ + id: 'parent-task', + displayId: 'parent', + blockedBy: ['task-a'], + }), + isError: false, + }, + ], + sourceToolUseID: 'tool-task-get', + sourceToolAssistantUUID: 'assistant-task-get', + toolUseResult: { + toolUseId: 'tool-task-get', + content: JSON.stringify({ + id: 'parent-task', + displayId: 'parent', + blockedBy: ['task-a'], + }), + }, + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + ], + ], + ]) + ), + }; + const summarySelector = { + selectSummaries: vi.fn(() => { + throw new Error('task_get result payload should not create recovered records'); + }), + }; + const runtimeFallbackSource = { + getTaskLogStream: vi.fn(async () => null), + }; + + const service = new BoardTaskLogStreamService( + { getTaskRecords: vi.fn(async () => []) } as never, + summarySelector as never, + strictParser as never, + undefined as never, + undefined as never, + taskReader as never, + transcriptSourceLocator as never, + runtimeFallbackSource as never + ); + + await expect(service.getTaskLogStream('demo', 'task-a')).resolves.toEqual({ + participants: [], + defaultFilter: 'all', + segments: [], + }); + expect(summarySelector.selectSummaries).not.toHaveBeenCalled(); + }); + it('extracts task_add_comment text from json-like tool result payload', async () => { const tom = { memberName: 'tom', @@ -948,4 +1365,137 @@ describe('BoardTaskLogStreamService', () => { }, ]); }); + + it('sanitizes MCP task_complete and message_send json payloads into readable results', async () => { + const tom = { + memberName: 'tom', + role: 'member' as const, + sessionId: 'session-tom', + isSidechain: false, + }; + const completeCandidate = { + ...makeCandidate('c-complete', '2026-04-12T16:00:00.000Z', tom, 'tool-complete'), + actionCategory: 'status' as const, + canonicalToolName: 'task_complete', + }; + const sendCandidate = { + ...makeCandidate('c-send', '2026-04-12T16:00:01.000Z', tom, 'tool-send'), + actionCategory: 'other' as const, + canonicalToolName: 'mcp__agent-teams__message_send', + }; + + const recordSource = { + getTaskRecords: vi.fn(async () => [ + ...completeCandidate.records, + ...sendCandidate.records, + ]), + }; + const summarySelector = { + selectSummaries: vi.fn(() => [completeCandidate, sendCandidate]), + }; + const strictParser = { + parseFiles: vi.fn(async () => new Map([['/tmp/task.jsonl', []]])), + }; + const detailSelector = { + selectDetail: vi.fn(({ candidate }: { candidate: BoardTaskExactLogBundleCandidate }) => { + const isComplete = candidate.id === 'c-complete'; + const toolUseId = isComplete ? 'tool-complete' : 'tool-send'; + const toolName = isComplete ? 'task_complete' : 'mcp__agent-teams__message_send'; + const payload = isComplete + ? { id: 'task-a', displayId: 'abcd1234', status: 'completed' } + : { + deliveredToInbox: true, + message: { + from: 'tom', + to: 'team-lead', + text: 'Detailed body', + summary: '#abcd1234 done', + }, + }; + + return { + id: candidate.id, + timestamp: candidate.timestamp, + actor: tom, + source: candidate.source, + records: candidate.records, + filteredMessages: [ + { + uuid: `${candidate.id}-assistant`, + parentUuid: null, + type: 'assistant' as const, + timestamp: new Date(candidate.timestamp), + role: 'assistant', + content: [{ type: 'tool_use', id: toolUseId, name: toolName, input: {} } as never], + toolCalls: [], + toolResults: [], + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + { + uuid: `${candidate.id}-result`, + parentUuid: `${candidate.id}-assistant`, + type: 'user' as const, + timestamp: new Date(candidate.timestamp), + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: toolUseId, + content: [{ type: 'text', text: JSON.stringify(payload) } as never], + } as never, + ], + toolCalls: [], + toolResults: [ + { + toolUseId, + content: [{ type: 'text', text: JSON.stringify(payload) }], + isError: false, + }, + ], + sourceToolUseID: toolUseId, + sourceToolAssistantUUID: `${candidate.id}-assistant`, + toolUseResult: { + toolUseId, + content: JSON.stringify(payload), + }, + isSidechain: false, + isMeta: false, + isCompactSummary: false, + }, + ], + }; + }), + }; + const buildBundleChunks = vi.fn((messages: ParsedMessage[]) => [{ id: messages[0]?.uuid }]); + + const service = new BoardTaskLogStreamService( + recordSource as never, + summarySelector as never, + strictParser as never, + detailSelector as never, + { buildBundleChunks } as never, + ); + + await service.getTaskLogStream('demo', 'task-a'); + + const mergedMessages = buildBundleChunks.mock.calls[0]?.[0] as ParsedMessage[]; + const completeResult = mergedMessages.find((message) => message.uuid === 'c-complete-result'); + const sendResult = mergedMessages.find((message) => message.uuid === 'c-send-result'); + expect(completeResult?.toolResults).toEqual([ + { + toolUseId: 'tool-complete', + content: 'Task abcd1234 completed', + isError: false, + }, + ]); + expect(sendResult?.toolResults).toEqual([ + { + toolUseId: 'tool-send', + content: 'Message sent to team-lead - #abcd1234 done', + isError: false, + }, + ]); + }); }); diff --git a/test/main/services/team/ChangeExtractorService.test.ts b/test/main/services/team/ChangeExtractorService.test.ts index fdd32b5b..302c35b1 100644 --- a/test/main/services/team/ChangeExtractorService.test.ts +++ b/test/main/services/team/ChangeExtractorService.test.ts @@ -1058,6 +1058,7 @@ describe('ChangeExtractorService', () => { projectDir, workspaceRoot: projectPath, attributionMode: 'strict-delivery', + evidenceMode: 'chain-only', }) ); expect(workerClient.computeTaskChanges).not.toHaveBeenCalled(); @@ -1182,6 +1183,7 @@ describe('ChangeExtractorService', () => { workspaceRoot: projectPath, deliveryContextPath: expect.stringContaining('delivery-context.json'), attributionMode: 'strict-delivery', + evidenceMode: 'chain-only', }) ); }); diff --git a/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts index 574cf63d..945c9da0 100644 --- a/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts +++ b/test/main/services/team/OpenCodeRuntimeManifestEvidenceReader.test.ts @@ -6,6 +6,7 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { OpenCodeRuntimeManifestEvidenceReader, + getOpenCodeRuntimeManifestPath, getOpenCodeLaneScopedRuntimeFilePath, getOpenCodeRuntimeLaneIndexPath, getOpenCodeTeamRuntimeDirectory, @@ -13,8 +14,10 @@ import { migrateLegacyOpenCodeRuntimeState, readOpenCodeRuntimeLaneIndex, recoverStaleOpenCodeRuntimeLaneIndexEntry, + setOpenCodeRuntimeActiveRunManifest, upsertOpenCodeRuntimeLaneIndexEntry, } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; +import { createDefaultRuntimeStoreManifest } from '../../../../src/main/services/team/opencode/store/RuntimeStoreManifest'; describe('OpenCodeRuntimeManifestEvidenceReader migration', () => { let tempDir: string; @@ -350,4 +353,98 @@ describe('OpenCodeRuntimeManifestEvidenceReader migration', () => { }, }); }); + + it('persists lane-scoped activeRunId for runtime evidence after app restart', async () => { + const teamName = 'team-theta'; + const laneId = 'secondary:opencode:jack'; + const reader = new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir }); + + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: tempDir, + teamName, + laneId, + runId: 'run-opencode-jack', + clock: () => now, + }); + + await expect(reader.read(teamName, laneId)).resolves.toMatchObject({ + activeRunId: 'run-opencode-jack', + highWatermark: 0, + }); + }); + + it('updates raw legacy runtime manifests without dropping existing capability metadata', async () => { + const teamName = 'team-iota'; + const laneId = 'secondary:opencode:alice'; + const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId); + const legacyManifest = { + ...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T10:00:00.000Z'), + activeRunId: 'run-old', + activeCapabilitySnapshotId: 'cap-existing', + activeBehaviorFingerprint: 'behavior-existing', + highWatermark: 5, + }; + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.writeFile(manifestPath, `${JSON.stringify(legacyManifest, null, 2)}\n`, 'utf8'); + + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: tempDir, + teamName, + laneId, + runId: 'run-new', + clock: () => now, + }); + + await expect( + new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir }).read(teamName, laneId) + ).resolves.toMatchObject({ + activeRunId: 'run-new', + capabilitySnapshotId: 'cap-existing', + highWatermark: 0, + }); + }); + + it('preserves committed manifest highWatermark when persisting activeRunId', async () => { + const teamName = 'team-kappa'; + const laneId = 'secondary:opencode:bob'; + const manifestPath = getOpenCodeRuntimeManifestPath(tempDir, teamName, laneId); + const committedManifest = { + ...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T10:00:00.000Z'), + activeRunId: 'run-old', + highWatermark: 5, + lastCommittedBatchId: 'batch-1', + entries: [ + { + schemaName: 'opencode.launchState', + schemaVersion: 1, + relativePath: 'launch-state.json', + contentHash: 'sha256:test', + fileSize: 12, + mtimeMs: 123, + runId: 'run-old', + capabilitySnapshotId: null, + behaviorFingerprint: null, + lastWriteReceiptId: 'receipt-1', + state: 'healthy', + }, + ], + }; + await fs.mkdir(path.dirname(manifestPath), { recursive: true }); + await fs.writeFile(manifestPath, `${JSON.stringify(committedManifest, null, 2)}\n`, 'utf8'); + + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: tempDir, + teamName, + laneId, + runId: 'run-new', + clock: () => now, + }); + + await expect( + new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempDir }).read(teamName, laneId) + ).resolves.toMatchObject({ + activeRunId: 'run-new', + highWatermark: 5, + }); + }); }); diff --git a/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts b/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts index e8cc20e9..7c7cd846 100644 --- a/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts +++ b/test/main/services/team/OpenCodeTaskLogStreamSource.fixture-e2e.test.ts @@ -32,6 +32,21 @@ const RELAY_WORKS_10_TASK: TeamTask = { ], }; +const RELAY_WORKS_10_COORDINATION_TASK: TeamTask = { + id: 'b5534868-0901-4c9e-9296-2b6e2059a08f', + displayId: 'b5534868', + subject: 'Split calculator implementation work', + owner: 'jack', + status: 'in_progress', + createdAt: '2026-04-24T20:28:58.000Z', + updatedAt: '2026-04-24T20:31:21.876Z', + workIntervals: [ + { + startedAt: '2026-04-24T20:28:58.000Z', + }, + ], +}; + async function loadFixtureTranscript(): Promise< NonNullable > { @@ -171,6 +186,43 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => { }); }); + it('includes real native OpenCode read/bash tools from a task-scoped runtime projection', async () => { + const transcript = await loadFixtureTranscript(); + const { source } = createSource({ + transcript, + activeTasks: [RELAY_WORKS_10_COORDINATION_TASK], + }); + + const response = await source.getTaskLogStream( + 'relay-works-10', + RELAY_WORKS_10_COORDINATION_TASK.id + ); + + expect(response).not.toBeNull(); + expect(response?.source).toBe('opencode_runtime_fallback'); + expect(response?.runtimeProjection).toMatchObject({ + provider: 'opencode', + mode: 'heuristic', + fallbackReason: 'task_tool_markers', + }); + expect(response?.runtimeProjection?.boardMcpToolCount).toBeGreaterThan(0); + expect(response?.runtimeProjection?.nativeToolCount).toBeGreaterThanOrEqual(2); + + const rawMessages = flattenRawMessages(response as BoardTaskLogStreamResponse); + const toolNames = rawMessages.flatMap((message) => + message.toolCalls.map((toolCall) => toolCall.name) + ); + const serialized = rawMessages.map(serializeContent).join('\n'); + + expect(toolNames).toEqual(expect.arrayContaining(['read', 'bash'])); + expect(toolNames).toEqual( + expect.arrayContaining(['agent-teams_task_start', 'agent-teams_task_add_comment']) + ); + expect(serialized).toContain('package.json'); + expect(serialized).toContain('Π Π°Π·Π±ΠΈΠ» Ρ€Π°Π±ΠΎΡ‚Ρƒ Π½Π° ΠΌΠ΅Π»ΠΊΠΈΠ΅ Π·Π°Π΄Π°Ρ‡ΠΈ'); + expect(toolNames).not.toContain('SendMessage'); + }); + it('uses real attribution UUID bounds before heuristic fallback', async () => { const transcript = await loadFixtureTranscript(); const { source, bridge, attributionStore } = createSource({ @@ -196,6 +248,8 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => { mode: 'attribution', attributionRecordCount: 1, projectedMessageCount: 10, + boardMcpToolCount: 4, + nativeToolCount: 0, }); expect(response?.defaultFilter).toBe('member:jack'); expect(response?.segments).toHaveLength(1); @@ -353,7 +407,9 @@ describe('OpenCodeTaskLogStreamSource real OpenCode fixture e2e', () => { const assistantToolIds = new Set( projectedMessages.flatMap((message) => message.toolCalls.map((toolCall) => toolCall.id)) ); - const toolResultMessages = projectedMessages.filter((message) => message.toolResults.length > 0); + const toolResultMessages = projectedMessages.filter( + (message) => message.toolResults.length > 0 + ); expect(projectedMessages).toHaveLength(101); expect(toolResultMessages.length).toBeGreaterThan(20); diff --git a/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts b/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts index 2cfe6b36..52c2ac3c 100644 --- a/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts +++ b/test/main/services/team/OpenCodeTaskLogStreamSource.test.ts @@ -233,6 +233,8 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 2, + boardMcpToolCount: 0, + nativeToolCount: 1, fallbackReason: 'no_attribution_records', }); expect(first?.participants).toEqual([ @@ -254,12 +256,78 @@ describe('OpenCodeTaskLogStreamSource', () => { expect(chunkBuilder.buildBundleChunks).toHaveBeenCalledTimes(1); expect(chunkBuilder.buildBundleChunks.mock.calls[0]?.[0]).toHaveLength(2); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual(['assistant-1', 'assistant-1::tool_results']); expect(bridge.getOpenCodeTranscript).toHaveBeenCalledTimes(1); expect(second).toEqual(first); }); + it('sanitizes OpenCode delivery retry envelopes from projected task log text', async () => { + const bridge = { + getOpenCodeTranscript: vi.fn(async () => ({ + sessionId: 'session-opencode', + logProjection: { + messages: [ + textLogMessage({ + uuid: 'task-delivery', + type: 'user', + role: 'user', + timestamp: '2026-04-21T10:05:00.000Z', + content: [ + { + type: 'text', + text: [ + '', + '', + 'This is retry attempt 3/3 for inbound app messageId "message-1".', + '', + '', + 'New task assigned to you: #task-a Investigate failing command', + '', + ].join('\n'), + }, + ], + }), + ], + }, + })), + }; + const chunkBuilder = { + buildBundleChunks: vi.fn((messages) => [ + { + id: 'chunk-sanitized', + kind: 'assistant', + messages, + }, + ]), + }; + const source = new OpenCodeTaskLogStreamSource( + bridge as never, + { resolve: async () => '/tmp/claude' }, + { + getTasks: async () => [createTask()], + getDeletedTasks: async () => [], + } as never, + chunkBuilder as never, + { readTaskRecords: vi.fn(async () => []) } + ); + + const response = await source.getTaskLogStream('team-a', 'task-a'); + + expect(response?.source).toBe('opencode_runtime_fallback'); + const projectedMessage = chunkBuilder.buildBundleChunks.mock.calls[0]?.[0]?.[0] as + | { content: Array<{ type: string; text?: string }> } + | undefined; + expect(projectedMessage?.content).toEqual([ + { + type: 'text', + text: 'New task assigned to you: #task-a Investigate failing command', + }, + ]); + }); + it('returns null when the task has no owner', async () => { const source = new OpenCodeTaskLogStreamSource( { getOpenCodeTranscript: vi.fn() } as never, @@ -465,12 +533,16 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 6, + boardMcpToolCount: 2, + nativeToolCount: 0, fallbackReason: 'task_tool_markers', markerMatchCount: 2, markerSpanCount: 1, }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual([ 'user-task-prompt', 'assistant-start', @@ -481,6 +553,226 @@ describe('OpenCodeTaskLogStreamSource', () => { ]); }); + it('keeps native OpenCode tools near task markers in the task stream', async () => { + const bridge = { + getOpenCodeTranscript: vi.fn(async () => ({ + sessionId: 'session-opencode', + logProjection: { + messages: [ + taskMarkerLogMessage({ + uuid: 'native-before-task', + timestamp: '2026-04-21T09:50:00.000Z', + toolName: 'read', + input: { filePath: '/tmp/unrelated.ts' }, + }), + textLogMessage({ + uuid: 'user-task-prompt', + type: 'user', + role: 'user', + timestamp: '2026-04-21T10:01:00.000Z', + content: [{ type: 'text', text: 'Start task-a now' }], + }), + taskMarkerLogMessage({ + uuid: 'assistant-start', + parentUuid: 'user-task-prompt', + timestamp: '2026-04-21T10:02:00.000Z', + toolName: 'mcp__agent-teams__task_start', + input: { teamName: 'team-a', taskId: 'task-a' }, + }), + toolResultLogMessage({ + uuid: 'assistant-start::tool_results', + parentUuid: 'assistant-start', + timestamp: '2026-04-21T10:02:01.000Z', + sourceToolAssistantUUID: 'assistant-start', + }), + taskMarkerLogMessage({ + uuid: 'native-read', + parentUuid: 'assistant-start::tool_results', + timestamp: '2026-04-21T10:03:00.000Z', + toolName: 'read', + input: { filePath: '/tmp/app.ts' }, + }), + toolResultLogMessage({ + uuid: 'native-read::tool_results', + parentUuid: 'native-read', + timestamp: '2026-04-21T10:03:01.000Z', + sourceToolAssistantUUID: 'native-read', + }), + taskMarkerLogMessage({ + uuid: 'native-bash', + parentUuid: 'native-read::tool_results', + timestamp: '2026-04-21T10:04:00.000Z', + toolName: 'bash', + input: { command: 'pnpm test' }, + }), + toolResultLogMessage({ + uuid: 'native-bash::tool_results', + parentUuid: 'native-bash', + timestamp: '2026-04-21T10:04:01.000Z', + sourceToolAssistantUUID: 'native-bash', + }), + taskMarkerLogMessage({ + uuid: 'assistant-comment', + parentUuid: 'native-bash::tool_results', + timestamp: '2026-04-21T10:05:00.000Z', + toolName: 'mcp__agent-teams__task_add_comment', + input: { teamName: 'team-a', taskId: 'task-a', text: 'Tests passed' }, + }), + toolResultLogMessage({ + uuid: 'assistant-comment::tool_results', + parentUuid: 'assistant-comment', + timestamp: '2026-04-21T10:05:01.000Z', + sourceToolAssistantUUID: 'assistant-comment', + }), + taskMarkerLogMessage({ + uuid: 'native-after-task', + timestamp: '2026-04-21T10:20:00.000Z', + toolName: 'bash', + input: { command: 'echo unrelated' }, + }), + ], + }, + })), + }; + const chunkBuilder = { + buildBundleChunks: vi.fn((messages) => [ + { + id: 'chunk-native-tools', + kind: 'assistant', + messages, + }, + ]), + }; + const source = new OpenCodeTaskLogStreamSource( + bridge as never, + { resolve: async () => '/tmp/claude' }, + { + getTasks: async () => [ + createTask({ + workIntervals: [ + { + startedAt: '2026-04-21T10:00:00.000Z', + completedAt: '2026-04-21T10:06:00.000Z', + }, + ], + }), + ], + getDeletedTasks: async () => [], + } as never, + chunkBuilder as never, + { readTaskRecords: vi.fn(async () => []) } + ); + + const response = await source.getTaskLogStream('team-a', 'task-a'); + + expect(response?.runtimeProjection).toEqual({ + provider: 'opencode', + mode: 'heuristic', + attributionRecordCount: 0, + projectedMessageCount: 9, + boardMcpToolCount: 2, + nativeToolCount: 2, + fallbackReason: 'task_tool_markers', + markerMatchCount: 2, + markerSpanCount: 1, + }); + expect( + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) + ).toEqual([ + 'user-task-prompt', + 'assistant-start', + 'assistant-start::tool_results', + 'native-read', + 'native-read::tool_results', + 'native-bash', + 'native-bash::tool_results', + 'assistant-comment', + 'assistant-comment::tool_results', + ]); + }); + + it('can include native OpenCode work shortly before a comment-only task marker', async () => { + const bridge = { + getOpenCodeTranscript: vi.fn(async () => ({ + sessionId: 'session-opencode', + logProjection: { + messages: [ + taskMarkerLogMessage({ + uuid: 'native-read', + timestamp: '2026-04-21T10:02:00.000Z', + toolName: 'read', + input: { filePath: '/tmp/app.ts' }, + }), + toolResultLogMessage({ + uuid: 'native-read::tool_results', + parentUuid: 'native-read', + timestamp: '2026-04-21T10:02:01.000Z', + sourceToolAssistantUUID: 'native-read', + }), + taskMarkerLogMessage({ + uuid: 'assistant-comment', + parentUuid: 'native-read::tool_results', + timestamp: '2026-04-21T10:04:00.000Z', + toolName: 'mcp__agent-teams__task_add_comment', + input: { teamName: 'team-a', taskId: 'task-a', text: 'Found the issue' }, + }), + toolResultLogMessage({ + uuid: 'assistant-comment::tool_results', + parentUuid: 'assistant-comment', + timestamp: '2026-04-21T10:04:01.000Z', + sourceToolAssistantUUID: 'assistant-comment', + }), + ], + }, + })), + }; + const chunkBuilder = { + buildBundleChunks: vi.fn((messages) => [ + { + id: 'chunk-comment-only-native', + kind: 'assistant', + messages, + }, + ]), + }; + const source = new OpenCodeTaskLogStreamSource( + bridge as never, + { resolve: async () => '/tmp/claude' }, + { + getTasks: async () => [createTask()], + getDeletedTasks: async () => [], + } as never, + chunkBuilder as never, + { readTaskRecords: vi.fn(async () => []) } + ); + + const response = await source.getTaskLogStream('team-a', 'task-a'); + + expect(response?.runtimeProjection).toEqual({ + provider: 'opencode', + mode: 'heuristic', + attributionRecordCount: 0, + projectedMessageCount: 4, + boardMcpToolCount: 1, + nativeToolCount: 1, + fallbackReason: 'task_tool_markers', + markerMatchCount: 1, + markerSpanCount: 1, + }); + expect( + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) + ).toEqual([ + 'native-read', + 'native-read::tool_results', + 'assistant-comment', + 'assistant-comment::tool_results', + ]); + }); + it('ignores OpenCode task markers that explicitly belong to another team', async () => { const bridge = { getOpenCodeTranscript: vi.fn(async () => ({ @@ -556,12 +848,16 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 3, + boardMcpToolCount: 1, + nativeToolCount: 0, fallbackReason: 'task_tool_markers', markerMatchCount: 1, markerSpanCount: 1, }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual(['team-a-prompt', 'team-a-start', 'team-a-start::tool_results']); }); @@ -679,12 +975,16 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 10, + boardMcpToolCount: 3, + nativeToolCount: 0, fallbackReason: 'task_tool_markers', markerMatchCount: 3, markerSpanCount: 2, }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual([ 'cycle-1-prompt', 'cycle-1-start', @@ -746,10 +1046,14 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 1, + boardMcpToolCount: 0, + nativeToolCount: 0, fallbackReason: 'no_attribution_records', }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual(['current-window-work']); }); @@ -802,12 +1106,16 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 0, projectedMessageCount: 2, + boardMcpToolCount: 1, + nativeToolCount: 0, fallbackReason: 'task_tool_markers', markerMatchCount: 1, markerSpanCount: 1, }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual(['display-ref-start', 'display-ref-start::tool_results']); }); @@ -891,6 +1199,8 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'attribution', attributionRecordCount: 1, projectedMessageCount: 1, + boardMcpToolCount: 0, + nativeToolCount: 0, }); expect(response?.participants).toEqual([ { @@ -909,7 +1219,9 @@ describe('OpenCodeTaskLogStreamSource', () => { isSidechain: true, }); expect( - chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls[0]?.[0].map( + (message: { uuid: string }) => message.uuid + ) ).toEqual(['bob-inside']); expect(bridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/claude', { teamId: 'team-a', @@ -1001,11 +1313,15 @@ describe('OpenCodeTaskLogStreamSource', () => { mode: 'heuristic', attributionRecordCount: 1, projectedMessageCount: 1, + boardMcpToolCount: 0, + nativeToolCount: 0, fallbackReason: 'attribution_no_projected_messages', }); expect(response?.participants[0]?.label).toBe('alice'); expect( - chunkBuilder.buildBundleChunks.mock.calls.at(-1)?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls + .at(-1)?.[0] + .map((message: { uuid: string }) => message.uuid) ).toEqual(['alice-inside']); expect(bridge.getOpenCodeTranscript).toHaveBeenNthCalledWith(1, '/tmp/claude', { teamId: 'team-a', @@ -1047,9 +1363,7 @@ describe('OpenCodeTaskLogStreamSource', () => { uuid: isBob ? 'bob-new-attribution' : 'alice-old-heuristic', parentUuid: undefined, type: 'assistant', - timestamp: isBob - ? '2026-04-21T12:05:00.000Z' - : '2026-04-21T10:05:00.000Z', + timestamp: isBob ? '2026-04-21T12:05:00.000Z' : '2026-04-21T10:05:00.000Z', role: 'assistant', content: [{ type: 'text', text: isBob ? 'new attribution' : 'old heuristic' }], isMeta: false, @@ -1104,7 +1418,9 @@ describe('OpenCodeTaskLogStreamSource', () => { limit: 500, }); expect( - chunkBuilder.buildBundleChunks.mock.calls.at(-1)?.[0].map((message: { uuid: string }) => message.uuid) + chunkBuilder.buildBundleChunks.mock.calls + .at(-1)?.[0] + .map((message: { uuid: string }) => message.uuid) ).toEqual(['bob-new-attribution']); }); }); diff --git a/test/main/services/team/ReviewApplierService.test.ts b/test/main/services/team/ReviewApplierService.test.ts index ed50464e..5b784788 100644 --- a/test/main/services/team/ReviewApplierService.test.ts +++ b/test/main/services/team/ReviewApplierService.test.ts @@ -189,6 +189,72 @@ describe('ReviewApplierService', () => { expect(unlink).toHaveBeenCalledWith(filePath); }); + it('ledger create reject blocks metadata-only create even when final hash is known', async () => { + const fsPromises = await import('fs/promises'); + const readFile = fsPromises.readFile as unknown as ReturnType; + const unlink = fsPromises.unlink as unknown as ReturnType; + + const { ReviewApplierService } = await import('@main/services/team/ReviewApplierService'); + const svc = new ReviewApplierService(); + const filePath = '/tmp/metadata-only-created.txt'; + const content = 'created\n'; + + const res = await svc.applyReviewDecisions( + { + teamName: 'team', + decisions: [{ filePath, fileDecision: 'rejected', hunkDecisions: { 0: 'rejected' } }], + }, + new Map([ + [ + filePath, + { + filePath, + relativePath: 'metadata-only-created.txt', + snippets: [ + { + toolUseId: 'ledger-1', + filePath, + toolName: 'Edit', + type: 'edit', + oldString: '', + newString: '', + replaceAll: false, + timestamp: '2026-03-01T10:00:00.000Z', + isError: false, + ledger: { + eventId: 'event-1', + source: 'ledger-snapshot', + confidence: 'medium', + originalFullContent: null, + modifiedFullContent: null, + beforeHash: null, + afterHash: sha(content), + operation: 'create', + beforeState: { + exists: false, + unavailableReason: 'gitless-before-content-unavailable', + }, + afterState: { exists: true, sha256: sha(content), sizeBytes: content.length }, + }, + }, + ], + linesAdded: 0, + linesRemoved: 0, + isNewFile: true, + originalFullContent: null, + modifiedFullContent: null, + contentSource: 'ledger-snapshot', + }, + ], + ]) + ); + + expect(res.applied).toBe(0); + expect(res.errors[0]?.code).toBe('manual-review-required'); + expect(readFile).not.toHaveBeenCalled(); + expect(unlink).not.toHaveBeenCalled(); + }); + it('ledger create reject blocks when current hash changed', async () => { const fsPromises = await import('fs/promises'); const readFile = fsPromises.readFile as unknown as ReturnType; diff --git a/test/main/services/team/TaskChangeLedgerReader.test.ts b/test/main/services/team/TaskChangeLedgerReader.test.ts index 52b4487b..a60807d0 100644 --- a/test/main/services/team/TaskChangeLedgerReader.test.ts +++ b/test/main/services/team/TaskChangeLedgerReader.test.ts @@ -241,6 +241,32 @@ describe('TaskChangeLedgerReader', () => { linesAdded: 1, linesRemoved: 1, }, + { + schemaVersion: 1, + eventId: 'event-apply-patch', + taskId: TASK_ID, + taskRef: TASK_ID, + taskRefKind: 'canonical', + phase: 'work', + executionSeq: 3, + sessionId: 'opencode-session-1', + memberName: 'bob', + toolUseId: 'part-apply-patch', + source: 'opencode_toolpart_apply_patch', + operation: 'modify', + confidence: 'medium', + workspaceRoot: '/repo', + filePath: '/repo/src/new.ts', + relativePath: 'src/new.ts', + timestamp: '2026-03-01T10:02:00.000Z', + toolStatus: 'succeeded', + before: null, + after: null, + beforeState: { exists: true, unavailableReason: 'opencode-apply-patch-before-content-unavailable' }, + afterState: { exists: true, unavailableReason: 'opencode-apply-patch-final-content-unavailable' }, + linesAdded: 0, + linesRemoved: 0, + }, ], }); @@ -254,8 +280,9 @@ describe('TaskChangeLedgerReader', () => { }); const snippets = result?.files[0]?.snippets ?? []; - expect(snippets.map((snippet) => snippet.toolName)).toEqual(['Write', 'Edit']); - expect(snippets.map((snippet) => snippet.type)).toEqual(['write-new', 'edit']); + expect(snippets.map((snippet) => snippet.toolName)).toEqual(['Write', 'Edit', 'Edit']); + expect(snippets.map((snippet) => snippet.type)).toEqual(['write-new', 'edit', 'edit']); + expect(snippets[2]?.ledger?.source).toBe('ledger-snapshot'); }); it('groups rename relations in summary-only bundles without losing absolute paths', async () => { diff --git a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts index f5ea6146..2ddce750 100644 --- a/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts +++ b/test/main/services/team/TeamAgentLaunchMatrix.safe-e2e.test.ts @@ -2,7 +2,7 @@ import { promises as fs } from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService'; import type { @@ -31,7 +31,9 @@ import { } from '../../../../src/main/utils/pathDecoder'; import { createPersistedLaunchSnapshot } from '../../../../src/main/services/team/TeamLaunchStateEvaluator'; import { + getOpenCodeRuntimeLaneIndexPath, readOpenCodeRuntimeLaneIndex, + setOpenCodeRuntimeActiveRunManifest, upsertOpenCodeRuntimeLaneIndexEntry, } from '../../../../src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; @@ -2004,6 +2006,57 @@ describe('Team agent launch matrix safe e2e', () => { ]); }); + it('launches mixed OpenCode lanes after a fresh abandoned lane index lock', async () => { + const teamName = 'mixed-opencode-abandoned-lane-lock-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + const lockPath = `${getOpenCodeRuntimeLaneIndexPath(getTeamsBasePath(), teamName)}.lock`; + const abandonedPid = 424_242; + await fs.mkdir(path.dirname(lockPath), { recursive: true }); + await fs.writeFile(lockPath, `${abandonedPid}\n${Date.now()}\n`, 'utf8'); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(((pid: number | string) => { + if (pid === abandonedPid) { + const error = new Error('process is gone') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + } + return true; + }) as typeof process.kill); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + run.child = { kill: () => undefined }; + trackLiveRun(svc, run); + + try { + await (svc as any).launchMixedSecondaryLaneIfNeeded(run); + } finally { + killSpy.mockRestore(); + } + + await waitForCondition(() => adapter.launchInputs.length === 2); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( + { + lanes: { + 'secondary:opencode:bob': { state: 'active' }, + 'secondary:opencode:tom': { state: 'active' }, + }, + } + ); + await waitForCondition(() => + run.mixedSecondaryLanes.every((lane: { state: string }) => lane.state === 'finished') + ); + expect(run.mixedSecondaryLanes.map((lane: { state: string }) => lane.state)).toEqual([ + 'finished', + 'finished', + ]); + }); + it('stopAllTeams stops in-flight mixed OpenCode secondary lanes without late failure degrading launch state', async () => { const teamName = 'mixed-opencode-stop-all-inflight-safe-e2e'; await writeMixedTeamConfig({ teamName, projectPath }); @@ -10398,7 +10451,7 @@ describe('Team agent launch matrix safe e2e', () => { const launchAdapter = new FakeOpenCodeRuntimeAdapter(); const firstService = new TeamProvisioningService(); firstService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([launchAdapter])); - await firstService.createTeam( + const launch = await firstService.createTeam( { teamName, cwd: projectPath, @@ -10426,6 +10479,7 @@ describe('Team agent launch matrix safe e2e', () => { }); expect(messageAdapter.messageInputs).toHaveLength(1); expect(messageAdapter.messageInputs[0]).toMatchObject({ + runId: launch.runId, teamName, laneId: 'primary', memberName: 'alice', @@ -10433,7 +10487,7 @@ describe('Team agent launch matrix safe e2e', () => { text: 'message recovered pure opencode lane', messageId: 'msg-recovered-pure-opencode', }); - expect(messageAdapter.messageInputs[0]?.runId).toBeUndefined(); + expect(messageAdapter.messageInputs[0]?.runId).toBe(launchAdapter.launchInputs[0]?.runId); }); it('delivers direct OpenCode member messages to recovered pure OpenCode lanes despite stale terminal provisioning state', async () => { @@ -10967,6 +11021,729 @@ describe('Team agent launch matrix safe e2e', () => { expect(adapter.messageInputs).toEqual([]); }); + it('does not scan or deliver orphaned mixed OpenCode lanes after app restart when team is stopped', async () => { + const teamName = 'mixed-opencode-stopped-orphaned-lane-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'secondary:opencode:bob', + state: 'active', + diagnostics: ['orphaned lane from previous app session'], + }); + await setOpenCodeRuntimeActiveRunManifest({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'secondary:opencode:bob', + runId: 'orphaned-opencode-run-bob', + }); + const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes'); + await fs.mkdir(inboxDir, { recursive: true }); + await fs.writeFile( + path.join(inboxDir, 'bob.json'), + `${JSON.stringify( + [ + { + from: 'user', + to: 'bob', + text: 'must not be delivered while parent team is stopped', + timestamp: '2026-04-23T10:01:00.000Z', + read: false, + messageId: 'msg-stopped-orphaned-lane-bob', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + }); + const restartedService = new TeamProvisioningService(); + restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + await expect(restartedService.scanOpenCodePromptDeliveryWatchdog(teamName)).resolves.toBe(0); + expect(adapter.stopInputs).toHaveLength(1); + expect(adapter.stopInputs[0]).toMatchObject({ + runId: 'orphaned-opencode-run-bob', + teamName, + laneId: 'secondary:opencode:bob', + providerId: 'opencode', + reason: 'cleanup', + force: true, + }); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject({ + lanes: {}, + }); + await expect( + restartedService.relayOpenCodeMemberInboxMessages(teamName, 'bob') + ).resolves.toMatchObject({ + relayed: 0, + failed: 1, + lastDelivery: { delivered: false, reason: 'opencode_runtime_not_active' }, + }); + await expect( + restartedService.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'direct message must not reach orphaned lane', + messageId: 'msg-stopped-orphaned-direct-bob', + }) + ).resolves.toEqual({ + delivered: false, + reason: 'opencode_runtime_not_active', + }); + + expect(adapter.reconcileInputs).toEqual([]); + expect(adapter.messageInputs).toEqual([]); + }); + + it('does not recover missing mixed OpenCode lanes from persisted runtime evidence when parent team is stopped', async () => { + const teamName = 'mixed-opencode-stopped-missing-lane-recovery-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await fs.writeFile( + path.join(getTeamsBasePath(), teamName, 'launch-state.json'), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + expectedMembers: ['alice', 'bob'], + leadSessionId: 'lead-session', + launchPhase: 'reconciled', + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + runtimePid: 7743, + runtimeSessionId: 'ses_bob_materialized', + livenessKind: 'runtime_process_candidate', + pidSource: 'opencode_bridge', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + updatedAt: '2026-04-23T10:00:00.000Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending', { + bob: 'launching', + }); + const restartedService = new TeamProvisioningService(); + restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + await expect( + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) + ).resolves.toMatchObject({ lanes: {} }); + await expect( + restartedService.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'must not recover stopped parent team', + messageId: 'msg-stopped-missing-lane-bob', + }) + ).resolves.toEqual({ + delivered: false, + reason: 'opencode_runtime_not_active', + }); + + expect(adapter.reconcileInputs).toEqual([]); + expect(adapter.messageInputs).toEqual([]); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject({ + lanes: {}, + }); + }); + + it('recovers a missing mixed OpenCode lane index from materialized persisted runtime evidence before direct delivery', async () => { + const teamName = 'mixed-opencode-direct-message-recovers-missing-lane-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await fs.writeFile( + path.join(getTeamsBasePath(), teamName, 'launch-state.json'), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + expectedMembers: ['alice', 'bob', 'tom'], + leadSessionId: 'lead-session', + launchPhase: 'reconciled', + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + runtimePid: 7743, + runtimeSessionId: 'ses_bob_materialized', + livenessKind: 'runtime_process_candidate', + pidSource: 'opencode_bridge', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + tom: { + name: 'tom', + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + updatedAt: '2026-04-23T10:00:00.000Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending', { + bob: 'launching', + tom: 'failed', + }); + await writeAliveProcessRegistry(teamName); + const restartedService = new TeamProvisioningService(); + restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + await expect( + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) + ).resolves.toMatchObject({ lanes: {} }); + await expect( + restartedService.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'recovered bob receives direct message', + messageId: 'msg-recovered-missing-lane-bob', + }) + ).resolves.toEqual({ + delivered: true, + diagnostics: [], + }); + + expect(adapter.reconcileInputs).toHaveLength(1); + expect(adapter.reconcileInputs[0]).toMatchObject({ + teamName, + laneId: 'secondary:opencode:bob', + reason: 'startup_recovery', + }); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( + { + lanes: { + 'secondary:opencode:bob': { + state: 'active', + }, + }, + } + ); + expect(adapter.messageInputs).toHaveLength(1); + expect(adapter.messageInputs[0]).toMatchObject({ + teamName, + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: projectPath, + text: 'recovered bob receives direct message', + messageId: 'msg-recovered-missing-lane-bob', + }); + }); + + it('recovers a missing mixed OpenCode lane index from confirmed-alive persisted runtime evidence before direct delivery', async () => { + const teamName = 'mixed-opencode-direct-message-recovers-confirmed-missing-lane-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await fs.writeFile( + path.join(getTeamsBasePath(), teamName, 'launch-state.json'), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + expectedMembers: ['alice', 'bob'], + leadSessionId: 'lead-session', + launchPhase: 'reconciled', + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimePid: 7743, + runtimeSessionId: 'ses_bob_confirmed_materialized', + livenessKind: 'runtime_process', + pidSource: 'opencode_bridge', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + updatedAt: '2026-04-23T10:00:00.000Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + }); + await writeAliveProcessRegistry(teamName); + const restartedService = new TeamProvisioningService(); + restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + await expect( + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) + ).resolves.toMatchObject({ lanes: {} }); + await expect( + restartedService.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'confirmed alive missing lane recovers', + messageId: 'msg-recovered-confirmed-missing-lane-bob', + }) + ).resolves.toEqual({ + delivered: true, + diagnostics: [], + }); + + expect(adapter.reconcileInputs).toHaveLength(1); + expect(adapter.reconcileInputs[0]).toMatchObject({ + teamName, + laneId: 'secondary:opencode:bob', + reason: 'startup_recovery', + }); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( + { + lanes: { + 'secondary:opencode:bob': { + state: 'active', + }, + }, + } + ); + expect(adapter.messageInputs[0]).toMatchObject({ + teamName, + laneId: 'secondary:opencode:bob', + memberName: 'bob', + text: 'confirmed alive missing lane recovers', + messageId: 'msg-recovered-confirmed-missing-lane-bob', + }); + }); + + it('recovers a missing mixed OpenCode lane index before watchdog scans unread OpenCode inbox', async () => { + const teamName = 'mixed-opencode-watchdog-recovers-missing-lane-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await fs.writeFile( + path.join(getTeamsBasePath(), teamName, 'launch-state.json'), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + expectedMembers: ['alice', 'bob', 'tom'], + leadSessionId: 'lead-session', + launchPhase: 'reconciled', + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + runtimePid: 7743, + runtimeSessionId: 'ses_bob_materialized', + livenessKind: 'runtime_process_candidate', + pidSource: 'opencode_bridge', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + tom: { + name: 'tom', + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: false, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + updatedAt: '2026-04-23T10:00:00.000Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes'); + await fs.mkdir(inboxDir, { recursive: true }); + await fs.writeFile( + path.join(inboxDir, 'bob.json'), + `${JSON.stringify( + [ + { + from: 'user', + to: 'bob', + text: 'recover this unread OpenCode message', + timestamp: '2026-04-23T10:01:00.000Z', + read: false, + messageId: 'msg-watchdog-recovers-missing-lane-bob', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending', { + bob: 'launching', + tom: 'failed', + }); + await writeAliveProcessRegistry(teamName); + const restartedService = new TeamProvisioningService(); + restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const scheduledWatchdogJobs: unknown[] = []; + (restartedService as any).scheduleOpenCodePromptDeliveryWatchdog = (input: unknown): void => { + scheduledWatchdogJobs.push(input); + }; + + await expect( + readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName) + ).resolves.toMatchObject({ lanes: {} }); + + await expect(restartedService.scanOpenCodePromptDeliveryWatchdog(teamName)).resolves.toBe(1); + + expect(adapter.reconcileInputs).toHaveLength(1); + expect(adapter.reconcileInputs[0]).toMatchObject({ + teamName, + laneId: 'secondary:opencode:bob', + reason: 'startup_recovery', + }); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( + { + lanes: { + 'secondary:opencode:bob': { + state: 'active', + }, + }, + } + ); + expect(scheduledWatchdogJobs).toEqual([ + expect.objectContaining({ + teamName, + memberName: 'bob', + messageId: 'msg-watchdog-recovers-missing-lane-bob', + delayMs: 500, + }), + ]); + }); + + it('recovers one missing mixed OpenCode lane before watchdog scans while sibling lane is active', async () => { + const teamName = 'mixed-opencode-watchdog-recovers-one-missing-lane-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await fs.writeFile( + path.join(getTeamsBasePath(), teamName, 'launch-state.json'), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + expectedMembers: ['alice', 'bob', 'tom'], + leadSessionId: 'lead-session', + launchPhase: 'reconciled', + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + runtimePid: 7743, + runtimeSessionId: 'ses_bob_materialized', + livenessKind: 'runtime_process_candidate', + pidSource: 'opencode_bridge', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + tom: { + name: 'tom', + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + laneId: 'secondary:opencode:tom', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + runtimePid: 7750, + runtimeSessionId: 'ses_tom_active', + livenessKind: 'runtime_process', + pidSource: 'opencode_bridge', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + updatedAt: '2026-04-23T10:00:00.000Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + await upsertOpenCodeRuntimeLaneIndexEntry({ + teamsBasePath: getTeamsBasePath(), + teamName, + laneId: 'secondary:opencode:tom', + state: 'active', + }); + const inboxDir = path.join(getTeamsBasePath(), teamName, 'inboxes'); + await fs.mkdir(inboxDir, { recursive: true }); + await fs.writeFile( + path.join(inboxDir, 'bob.json'), + `${JSON.stringify( + [ + { + from: 'user', + to: 'bob', + text: 'recover only bob while tom stays active', + timestamp: '2026-04-23T10:01:00.000Z', + read: false, + messageId: 'msg-watchdog-recovers-one-missing-lane-bob', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); + const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending', { + bob: 'launching', + tom: 'confirmed', + }); + await writeAliveProcessRegistry(teamName); + const restartedService = new TeamProvisioningService(); + restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const scheduledWatchdogJobs: unknown[] = []; + (restartedService as any).scheduleOpenCodePromptDeliveryWatchdog = (input: unknown): void => { + scheduledWatchdogJobs.push(input); + }; + + await expect(restartedService.scanOpenCodePromptDeliveryWatchdog(teamName)).resolves.toBe(1); + + expect(adapter.reconcileInputs).toHaveLength(1); + expect(adapter.reconcileInputs[0]).toMatchObject({ + teamName, + laneId: 'secondary:opencode:bob', + reason: 'startup_recovery', + }); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( + { + lanes: { + 'secondary:opencode:bob': { + state: 'active', + }, + 'secondary:opencode:tom': { + state: 'active', + }, + }, + } + ); + expect(scheduledWatchdogJobs).toEqual([ + expect.objectContaining({ + teamName, + memberName: 'bob', + messageId: 'msg-watchdog-recovers-one-missing-lane-bob', + delayMs: 500, + }), + ]); + }); + + it('does not recover a missing mixed OpenCode lane index from liveness-only persisted metadata', async () => { + const teamName = 'mixed-opencode-direct-message-liveness-only-missing-lane-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + await fs.writeFile( + path.join(getTeamsBasePath(), teamName, 'launch-state.json'), + `${JSON.stringify( + createPersistedLaunchSnapshot({ + teamName, + expectedMembers: ['alice', 'bob'], + leadSessionId: 'lead-session', + launchPhase: 'reconciled', + members: { + alice: { + name: 'alice', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'opencode', + model: 'opencode/minimax-m2.5-free', + laneId: 'secondary:opencode:bob', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + livenessKind: 'runtime_process_candidate', + pidSource: 'opencode_bridge', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + updatedAt: '2026-04-23T10:00:00.000Z', + }), + null, + 2 + )}\n`, + 'utf8' + ); + const adapter = new FakeOpenCodeRuntimeAdapter('partial_pending', { + bob: 'launching', + }); + const restartedService = new TeamProvisioningService(); + restartedService.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + + await expect( + restartedService.deliverOpenCodeMemberMessage(teamName, { + memberName: 'bob', + text: 'must not recover from liveness-only stale metadata', + messageId: 'msg-liveness-only-missing-lane-bob', + }) + ).resolves.toEqual({ + delivered: false, + reason: 'opencode_runtime_not_active', + }); + + expect(adapter.reconcileInputs).toEqual([]); + expect(adapter.messageInputs).toEqual([]); + await expect(readOpenCodeRuntimeLaneIndex(getTeamsBasePath(), teamName)).resolves.toMatchObject( + { lanes: {} } + ); + }); + it('does not deliver direct OpenCode member messages to one detached mixed lane while its sibling lane stays live', async () => { const teamName = 'mixed-opencode-direct-message-one-detached-lane-safe-e2e'; await writeMixedTeamConfig({ teamName, projectPath }); @@ -13664,6 +14441,87 @@ describe('Team agent launch matrix safe e2e', () => { }); }); + it('fresh relaunches a failed mixed OpenCode teammate without runtime evidence', async () => { + const teamName = 'mixed-opencode-fresh-relaunch-no-runtime-evidence-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + const adapter = new FakeOpenCodeRuntimeAdapter('clean_success', { + bob: 'confirmed', + tom: 'confirmed', + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + run.mixedSecondaryLanes = run.mixedSecondaryLanes.filter( + (lane: { member: { name: string } }) => lane.member.name !== 'bob' + ); + run.memberSpawnStatuses.set('bob', { + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'File lock timeout: lanes.json', + error: 'File lock timeout: lanes.json', + agentToolAccepted: false, + updatedAt: '2026-04-24T12:00:00.000Z', + } as never); + trackLiveRun(svc, run); + + await svc.restartMember(teamName, 'bob'); + + await waitForCondition(() => adapter.launchInputs.length === 1); + expect(adapter.stopInputs).toHaveLength(0); + expect(adapter.launchInputs[0]).toMatchObject({ + teamName, + laneId: 'secondary:opencode:bob', + expectedMembers: [expect.objectContaining({ name: 'bob', providerId: 'opencode' })], + }); + const bobLane = run.mixedSecondaryLanes.find( + (lane: { member: { name: string } }) => lane.member.name === 'bob' + ); + expect(bobLane).toMatchObject({ + laneId: 'secondary:opencode:bob', + diagnostics: expect.arrayContaining([ + 'controlled_reattach:manual_restart', + 'fresh_relaunch:no_runtime_evidence', + ]), + }); + const statuses = await svc.getMemberSpawnStatuses(teamName); + expect(statuses.statuses.bob).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + hardFailure: false, + }); + }); + + it('rejects duplicate fresh relaunch while a mixed OpenCode lane is queued', async () => { + const teamName = 'mixed-opencode-fresh-relaunch-queued-reject-safe-e2e'; + await writeMixedTeamConfig({ teamName, projectPath }); + await writeTeamMeta(teamName, projectPath); + await writeMembersMeta(teamName); + const adapter = new FakeOpenCodeRuntimeAdapter(); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry(new TeamRuntimeAdapterRegistry([adapter])); + const run = createMixedLiveRun({ teamName, projectPath }); + trackLiveRun(svc, run); + + await expect(svc.restartMember(teamName, 'bob')).rejects.toThrow( + 'Restart for teammate "bob" is already in progress' + ); + + expect(adapter.launchInputs).toHaveLength(0); + expect(adapter.stopInputs).toHaveLength(0); + expect( + run.mixedSecondaryLanes.find( + (lane: { member: { name: string } }) => lane.member.name === 'bob' + ) + ).toMatchObject({ + state: 'queued', + }); + }); + it('reattaches an existing mixed OpenCode teammate after member update without changing siblings', async () => { const teamName = 'mixed-opencode-update-member-reattach-safe-e2e'; await writeMixedTeamConfig({ teamName, projectPath }); @@ -16538,6 +17396,27 @@ function trackLiveRun(svc: TeamProvisioningService, run: any): void { (svc as any).aliveRunByTeam.set(run.teamName, run.runId); } +async function writeAliveProcessRegistry(teamName: string): Promise { + const teamDir = path.join(getTeamsBasePath(), teamName); + await fs.mkdir(teamDir, { recursive: true }); + await fs.writeFile( + path.join(teamDir, 'processes.json'), + `${JSON.stringify( + [ + { + id: 'lead-process', + label: 'Team Lead', + pid: process.pid, + registeredAt: '2026-04-23T10:00:00.000Z', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); +} + function expectDirectChildKillCount(actual: number, expected: number): void { // Windows uses taskkill.exe for process-tree termination, so fake child.kill is not called. expect(actual).toBe(process.platform === 'win32' ? 0 : expected); diff --git a/test/main/services/team/TeamMemberWorktreeManager.test.ts b/test/main/services/team/TeamMemberWorktreeManager.test.ts index 45f8538f..5ae62587 100644 --- a/test/main/services/team/TeamMemberWorktreeManager.test.ts +++ b/test/main/services/team/TeamMemberWorktreeManager.test.ts @@ -8,10 +8,12 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; const hoisted = vi.hoisted(() => ({ claudeRoot: '', + appDataRoot: '', })); vi.mock('@main/utils/pathDecoder', () => ({ getClaudeBasePath: () => hoisted.claudeRoot, + getAppDataPath: () => hoisted.appDataRoot, })); import { TeamMemberWorktreeManager } from '../../../../src/main/services/team/TeamMemberWorktreeManager'; @@ -43,6 +45,26 @@ function shortHash(value: string): string { return createHash('sha256').update(value).digest('hex').slice(0, 10); } +function expectedWorktreePath(repoPath: string, teamName = 'Atlas HQ', memberName = 'Bob'): string { + return path.join( + hoisted.appDataRoot, + 'team-worktrees', + `${slugify(path.basename(repoPath))}-${shortHash(repoPath)}`, + slugify(teamName), + slugify(memberName) + ); +} + +function legacyWorktreePath(repoPath: string, teamName = 'Atlas HQ', memberName = 'Bob'): string { + return path.join( + hoisted.claudeRoot, + 'team-worktrees', + shortHash(repoPath), + slugify(teamName), + slugify(memberName) + ); +} + async function createGitRepo(root: string): Promise { const repoPath = path.join(root, 'repo'); await fs.mkdir(repoPath, { recursive: true }); @@ -59,7 +81,9 @@ describe('TeamMemberWorktreeManager', () => { beforeEach(async () => { tempRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'team-member-worktree-')); hoisted.claudeRoot = path.join(tempRoot, 'claude'); + hoisted.appDataRoot = path.join(tempRoot, 'app-data'); await fs.mkdir(hoisted.claudeRoot, { recursive: true }); + await fs.mkdir(hoisted.appDataRoot, { recursive: true }); }); afterEach(async () => { @@ -78,23 +102,37 @@ describe('TeamMemberWorktreeManager', () => { expect(resolution.baseRepoPath).toBe(repoPath); expect(resolution.branchName).toBe(`agent-teams/atlas-hq/bob-${shortHash(repoPath)}`); - expect(resolution.worktreePath).toBe( - path.join(hoisted.claudeRoot, 'team-worktrees', shortHash(repoPath), 'atlas-hq', 'bob') - ); + expect(resolution.worktreePath).toBe(expectedWorktreePath(repoPath)); + expect(resolution.worktreePath.startsWith(hoisted.appDataRoot)).toBe(true); + expect(resolution.worktreePath.startsWith(hoisted.claudeRoot)).toBe(false); await expect(execGit(['rev-parse', '--abbrev-ref', 'HEAD'], resolution.worktreePath)).resolves.toBe( resolution.branchName ); }); + it('reuses legacy deterministic worktree paths for existing teammates', async () => { + const repoPath = await createGitRepo(tempRoot); + const manager = new TeamMemberWorktreeManager(); + const branchName = `agent-teams/atlas-hq/bob-${shortHash(repoPath)}`; + const legacyPath = legacyWorktreePath(repoPath); + await fs.mkdir(path.dirname(legacyPath), { recursive: true }); + await execGit(['worktree', 'add', '-b', branchName, legacyPath, 'HEAD'], repoPath); + + const resolution = await manager.ensureMemberWorktree({ + teamName: 'Atlas HQ', + memberName: 'Bob', + baseCwd: repoPath, + }); + + expect(resolution.worktreePath).toBe(legacyPath); + await expect(execGit(['rev-parse', '--abbrev-ref', 'HEAD'], resolution.worktreePath)).resolves.toBe( + branchName + ); + }); + it('rejects an existing deterministic path checked out on the wrong branch', async () => { const repoPath = await createGitRepo(tempRoot); - const wrongPath = path.join( - hoisted.claudeRoot, - 'team-worktrees', - shortHash(repoPath), - slugify('Atlas HQ'), - slugify('Bob') - ); + const wrongPath = expectedWorktreePath(repoPath); await fs.mkdir(path.dirname(wrongPath), { recursive: true }); await execGit(['worktree', 'add', '-b', 'some-other-branch', wrongPath, 'HEAD'], repoPath); diff --git a/test/main/services/team/TeamProvisioningService.test.ts b/test/main/services/team/TeamProvisioningService.test.ts index f8ff768d..c41f9886 100644 --- a/test/main/services/team/TeamProvisioningService.test.ts +++ b/test/main/services/team/TeamProvisioningService.test.ts @@ -30,6 +30,7 @@ vi.mock('@features/tmux-installer/main', () => ({ listRuntimeProcessesForCurrentTmuxPlatform: vi.fn(async () => []), listTmuxPanePidsForCurrentPlatform: vi.fn(async () => new Map()), listTmuxPaneRuntimeInfoForCurrentPlatform: vi.fn(async () => new Map()), + sendKeysToTmuxPaneForCurrentPlatform: vi.fn(async () => undefined), isTmuxRuntimeReadyForCurrentPlatform: vi.fn(async () => true), })); @@ -133,6 +134,7 @@ import { getTeamLaunchStatePath } from '@main/services/team/TeamLaunchStateStore import { getOpenCodeLaneScopedRuntimeFilePath, getOpenCodeRuntimeManifestPath, + OpenCodeRuntimeManifestEvidenceReader, readOpenCodeRuntimeLaneIndex, upsertOpenCodeRuntimeLaneIndexEntry, } from '@main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader'; @@ -151,6 +153,7 @@ import { listRuntimeProcessesForCurrentTmuxPlatform, listTmuxPanePidsForCurrentPlatform, listTmuxPaneRuntimeInfoForCurrentPlatform, + sendKeysToTmuxPaneForCurrentPlatform, } from '@features/tmux-installer/main'; import pidusage from 'pidusage'; @@ -274,6 +277,27 @@ function writeBootstrapState( ); } +function writeAliveProcessRegistry(teamName: string): void { + const teamDir = path.join(tempTeamsBase, teamName); + fs.mkdirSync(teamDir, { recursive: true }); + fs.writeFileSync( + path.join(teamDir, 'processes.json'), + `${JSON.stringify( + [ + { + id: 'lead-process', + label: 'Team Lead', + pid: process.pid, + registeredAt: '2026-04-23T10:00:00.000Z', + }, + ], + null, + 2 + )}\n`, + 'utf8' + ); +} + function writeTeamMeta(teamName: string, overrides: Record = {}): void { const teamDir = path.join(tempTeamsBase, teamName); fs.mkdirSync(teamDir, { recursive: true }); @@ -422,6 +446,8 @@ describe('TeamProvisioningService', () => { vi.mocked(listTmuxPanePidsForCurrentPlatform).mockResolvedValue(new Map()); vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockReset(); vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValue(new Map()); + vi.mocked(sendKeysToTmuxPaneForCurrentPlatform).mockReset(); + vi.mocked(sendKeysToTmuxPaneForCurrentPlatform).mockResolvedValue(undefined); tempClaudeRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'claude-team-provisioning-')); tempTeamsBase = path.join(tempClaudeRoot, 'teams'); tempTasksBase = path.join(tempClaudeRoot, 'tasks'); @@ -433,6 +459,7 @@ describe('TeamProvisioningService', () => { fs.mkdirSync(tempTeamsBase, { recursive: true }); fs.mkdirSync(tempTasksBase, { recursive: true }); fs.mkdirSync(tempProjectsBase, { recursive: true }); + writeAliveProcessRegistry('team-a'); }); afterEach(() => { @@ -603,6 +630,42 @@ describe('TeamProvisioningService', () => { }); }); + it('does not send legacy process backend pane markers to tmux liveness lookup', async () => { + const svc = new TeamProvisioningService(); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { name: 'alice', model: 'gpt-5.4-mini' }, + ], + })), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'alice', + agentId: 'alice@runtime-team', + tmuxPaneId: 'process:4242', + }, + ]); + (svc as any).aliveRunByTeam.set('runtime-team', 'run-1'); + (svc as any).runs.set('run-1', { + runId: 'run-1', + child: { pid: 111 }, + request: { model: 'gpt-5.4' }, + processKilled: false, + cancelRequested: false, + spawnContext: null, + }); + vi.mocked(pidusage).mockResolvedValueOnce({ + '111': createPidusageStat(111, 123_000_000), + } as any); + + await svc.getTeamAgentRuntimeSnapshot('runtime-team'); + + expect(listTmuxPaneRuntimeInfoForCurrentPlatform).not.toHaveBeenCalled(); + expect(listTmuxPanePidsForCurrentPlatform).not.toHaveBeenCalled(); + }); + it('exposes providerBackendId from the live run request when available', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { @@ -1221,7 +1284,7 @@ describe('TeamProvisioningService', () => { }); }); - it('shows RSS for OpenCode secondary lanes through the shared runtime host without exposing a member pid', async () => { + it('shows RSS for OpenCode secondary lane host pids without treating pre-bootstrap runtime as alive', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { getConfig: vi.fn(async () => ({ @@ -1324,15 +1387,16 @@ describe('TeamProvisioningService', () => { expect(pidusage).toHaveBeenCalledWith(333, { maxage: 0 }); expect(snapshot.members.bob).toMatchObject({ memberName: 'bob', - alive: true, + alive: false, restartable: false, pid: 333, runtimeModel: 'opencode/minimax-m2.5-free', rssBytes: 456_000_000, + livenessKind: 'runtime_process_candidate', }); }); - it('shows RSS for persisted OpenCode secondary lane runtime pids after the launch run is gone', async () => { + it('shows RSS for persisted OpenCode secondary lane host pids without treating historical bootstrap as live', async () => { const svc = new TeamProvisioningService(); (svc as any).configReader = { getConfig: vi.fn(async () => ({ @@ -1398,12 +1462,14 @@ describe('TeamProvisioningService', () => { expect(pidusage).toHaveBeenCalledWith([333], { maxage: 0 }); expect(snapshot.members.bob).toMatchObject({ memberName: 'bob', - alive: true, + alive: false, restartable: false, pid: 333, providerId: 'opencode', runtimeModel: 'opencode/minimax-m2.5-free', rssBytes: 456_000_000, + historicalBootstrapConfirmed: true, + livenessKind: 'runtime_process_candidate', }); }); }); @@ -1623,6 +1689,178 @@ describe('TeamProvisioningService', () => { ); }); + it('restarts a tmux teammate directly in its shell-only pane after the runtime process disappeared', async () => { + const teamName = 'forge-labs-10'; + const teamDir = path.join(tempTeamsBase, teamName); + const projectPath = path.join(tempClaudeRoot, 'forge-project'); + fs.mkdirSync(teamDir, { recursive: true }); + fs.mkdirSync(projectPath, { recursive: true }); + fs.writeFileSync( + path.join(teamDir, 'config.json'), + JSON.stringify( + { + name: 'Forge Labs 10', + projectPath, + leadSessionId: 'lead-session-1', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { + name: 'bob', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'high', + agentType: 'general-purpose', + tmuxPaneId: '%1', + backendType: 'tmux', + }, + ], + }, + null, + 2 + ), + 'utf8' + ); + + vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); + vi.mocked(listTmuxPaneRuntimeInfoForCurrentPlatform).mockResolvedValue( + new Map([ + [ + '%1', + { + paneId: '%1', + panePid: 4242, + currentCommand: 'zsh', + currentPath: projectPath, + }, + ], + ]) + ); + + const svc = new TeamProvisioningService(undefined, undefined, undefined, undefined, { + writeConfigFile: vi.fn(async () => '/mock/mcp-config.json'), + } as any); + const run = createMemberSpawnRun({ + teamName, + expectedMembers: ['bob'], + memberSpawnStatuses: new Map([ + [ + 'bob', + createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'Teammate was never spawned during launch.', + error: 'Teammate was never spawned during launch.', + agentToolAccepted: false, + firstSpawnAcceptedAt: undefined, + }), + ], + ]), + }); + run.child = { pid: 111 }; + run.processKilled = false; + run.cancelRequested = false; + run.detectedSessionId = 'lead-session-1'; + run.request = { providerId: 'codex', skipPermissions: true }; + + const sendMessageToRun = vi.fn(async () => {}); + (svc as any).sendMessageToRun = sendMessageToRun; + (svc as any).buildProvisioningEnv = vi.fn(async () => ({ + env: { OPENAI_API_KEY: 'test-openai-key' }, + authSource: 'openai_api_key', + providerArgs: [], + })); + (svc as any).configReader = { + getConfig: vi.fn(async () => ({ + name: 'Forge Labs 10', + projectPath, + leadSessionId: 'lead-session-1', + members: [ + { name: 'team-lead', agentType: 'team-lead' }, + { + name: 'bob', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'high', + }, + ], + })), + }; + (svc as any).membersMetaStore = { + getMembers: vi.fn(async () => [ + { + name: 'bob', + role: 'Developer', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'high', + agentType: 'general-purpose', + }, + ]), + }; + (svc as any).readPersistedRuntimeMembers = vi.fn(() => [ + { + name: 'bob', + agentId: 'bob@forge-labs-10', + backendType: 'tmux', + tmuxPaneId: '%1', + cwd: projectPath, + }, + ]); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + (svc as any).aliveRunByTeam.set(teamName, run.runId); + (svc as any).runs.set(run.runId, run); + + await svc.restartMember(teamName, 'bob'); + + expect(killTmuxPaneForCurrentPlatformSync).not.toHaveBeenCalled(); + expect(sendMessageToRun).not.toHaveBeenCalled(); + expect(sendKeysToTmuxPaneForCurrentPlatform).toHaveBeenCalledTimes(1); + const [paneId, command] = vi.mocked(sendKeysToTmuxPaneForCurrentPlatform).mock.calls[0] ?? []; + expect(paneId).toBe('%1'); + expect(command).toContain("cd '"); + expect(command).toContain(projectPath); + expect(command).toContain("'/mock/claude'"); + expect(command).toContain("'--agent-id' 'bob@forge-labs-10'"); + expect(command).toContain("'--team-name' 'forge-labs-10'"); + expect(command).toContain("'--parent-session-id' 'lead-session-1'"); + expect(command).toContain("'--mcp-config' '/mock/mcp-config.json'"); + expect(command).toContain("'--model' 'gpt-5.4'"); + expect(command).toContain("'--effort' 'high'"); + expect(command).toContain('__CLAUDE_TEAMMATE_EXIT__'); + expect(run.pendingMemberRestarts.has('bob')).toBe(true); + expect(run.memberSpawnStatuses.get('bob')).toMatchObject({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + hardFailure: false, + }); + + const updatedConfig = JSON.parse( + fs.readFileSync(path.join(teamDir, 'config.json'), 'utf8') + ) as { members: Array> }; + expect(updatedConfig.members.find((member) => member.name === 'bob')).toMatchObject({ + agentId: 'bob@forge-labs-10', + tmuxPaneId: '%1', + backendType: 'tmux', + providerId: 'codex', + model: 'gpt-5.4', + effort: 'high', + }); + const inbox = JSON.parse( + fs.readFileSync(path.join(teamDir, 'inboxes', 'bob.json'), 'utf8') + ) as Array>; + expect(inbox.at(-1)).toMatchObject({ + from: 'team-lead', + to: 'bob', + source: 'system_notification', + leadSessionId: 'lead-session-1', + }); + }); + it('skips a failed teammate for the current launch without marking it alive', async () => { const svc = new TeamProvisioningService(); const run = createMemberSpawnRun({ @@ -3094,6 +3332,7 @@ describe('TeamProvisioningService', () => { ); (svc as any).getTrackedRunId = vi.fn(() => null); + (svc as any).canDeliverToOpenCodeRuntimeForTeam = vi.fn(() => true); (svc as any).resolveCurrentOpenCodeRuntimeRunId = vi.fn(async () => 'opencode-run-bob'); (svc as any).isOpenCodeRuntimeLaneIndexActive = vi.fn(async () => true); (svc as any).configReader = { @@ -4376,9 +4615,11 @@ describe('TeamProvisioningService', () => { sessionId: 'oc-session-bob', prePromptCursor: 'cursor-before', responseObservation: { - state: sendMessageToMember.mock.calls.length === 1 ? 'responded_non_visible_tool' : 'pending', + state: + sendMessageToMember.mock.calls.length === 1 ? 'responded_non_visible_tool' : 'pending', deliveredUserMessageId: 'oc-user-ask', - assistantMessageId: sendMessageToMember.mock.calls.length === 1 ? 'oc-assistant-read' : null, + assistantMessageId: + sendMessageToMember.mock.calls.length === 1 ? 'oc-assistant-read' : null, toolCallNames: sendMessageToMember.mock.calls.length === 1 ? ['read'] : [], visibleMessageToolCallId: null, visibleReplyMessageId: null, @@ -4510,6 +4751,102 @@ describe('TeamProvisioningService', () => { expect(retryText).toContain('What did you find?'); }); + it('keeps OpenCode task delivery pending after read-only non-visible tool activity', async () => { + const svc = new TeamProvisioningService(); + const sendMessageToMember = vi.fn(async (input: Record) => ({ + 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-task', + assistantMessageId: 'oc-assistant-read', + toolCallNames: ['read', 'bash'], + visibleMessageToolCallId: null, + visibleReplyMessageId: null, + visibleReplyCorrelation: null, + latestAssistantPreview: null, + reason: null, + }, + diagnostics: [], + })); + const registry = new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: vi.fn(), + stop: vi.fn(), + sendMessageToMember, + observeMessageDelivery: vi.fn(), + } as any, + ]); + svc.setRuntimeAdapterRegistry(registry); + + (svc as any).getTrackedRunId = vi.fn(() => 'run-1'); + (svc as any).provisioningRunByTeam.set('team-a', 'run-1'); + (svc as any).setSecondaryRuntimeRun({ + teamName: 'team-a', + runId: 'opencode-run-bob', + providerId: 'opencode', + laneId: 'secondary:opencode:bob', + memberName: 'bob', + cwd: '/repo', + }); + (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('team-a', { + memberName: 'bob', + text: 'Start task #task-1 now.', + messageId: 'msg-task-read-only', + replyRecipient: 'team-lead', + actionMode: 'do', + 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 () => { const svc = new TeamProvisioningService(); const emptyResponseObservation = { @@ -5191,10 +5528,40 @@ describe('TeamProvisioningService', () => { diagnostics: [], }, ]; + const manifestPath = getOpenCodeRuntimeManifestPath( + tempTeamsBase, + teamName, + 'secondary:opencode:bob' + ); + await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true }); + await fsPromises.writeFile( + manifestPath, + `${JSON.stringify( + { + ...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T10:00:00.000Z'), + activeRunId: 'stale-run', + highWatermark: 2, + }, + null, + 2 + )}\n`, + 'utf8' + ); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); await vi.waitFor(async () => { expect(adapterLaunch).toHaveBeenCalledTimes(1); + const launchInput = adapterLaunch.mock.calls[0]?.[0] as { runId?: string } | undefined; + expect(launchInput?.runId).toEqual(expect.any(String)); + await expect( + new OpenCodeRuntimeManifestEvidenceReader({ teamsBasePath: tempTeamsBase }).read( + teamName, + 'secondary:opencode:bob' + ) + ).resolves.toMatchObject({ + activeRunId: launchInput?.runId, + highWatermark: 0, + }); await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ lanes: { 'secondary:opencode:bob': { @@ -7622,7 +7989,7 @@ describe('TeamProvisioningService', () => { ); await (svc as any).launchMixedSecondaryLaneIfNeeded(run); - await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(2)); + await vi.waitFor(() => expect(adapterLaunch).toHaveBeenCalledTimes(2), { timeout: 5_000 }); expect(adapterLaunch).toHaveBeenCalledWith( expect.objectContaining({ laneId: 'secondary:opencode:bob', @@ -7653,20 +8020,23 @@ describe('TeamProvisioningService', () => { ], }) ); - await vi.waitFor(() => { - expect(run.mixedSecondaryLanes).toEqual([ - expect.objectContaining({ - laneId: 'secondary:opencode:bob', - state: 'finished', - result: expect.objectContaining({ teamLaunchState: 'clean_success' }), - }), - expect.objectContaining({ - laneId: 'secondary:opencode:tom', - state: 'finished', - result: expect.objectContaining({ teamLaunchState: 'clean_success' }), - }), - ]); - }); + await vi.waitFor( + () => { + expect(run.mixedSecondaryLanes).toEqual([ + expect.objectContaining({ + laneId: 'secondary:opencode:bob', + state: 'finished', + result: expect.objectContaining({ teamLaunchState: 'clean_success' }), + }), + expect.objectContaining({ + laneId: 'secondary:opencode:tom', + state: 'finished', + result: expect.objectContaining({ teamLaunchState: 'clean_success' }), + }), + ]); + }, + { timeout: 5_000 } + ); const publicStatuses = await svc.getMemberSpawnStatuses('safe-mixed-codex-opencode-launch'); expect(publicStatuses.statuses.bob).toMatchObject({ status: 'online', @@ -7683,6 +8053,110 @@ describe('TeamProvisioningService', () => { await svc.cancelProvisioning(runId); }); + it('restores missing OpenCode teammates into config before post-launch registration audit', async () => { + allowConsoleLogs(); + const teamName = 'mixed-opencode-post-launch-config'; + const teamDir = path.join(tempTeamsBase, teamName); + const jackWorktree = path.join(tempClaudeRoot, 'worktrees', 'jack'); + fs.mkdirSync(teamDir, { recursive: true }); + fs.writeFileSync( + path.join(teamDir, 'config.json'), + `${JSON.stringify( + { + name: teamName, + projectPath: '/old/project', + leadSessionId: 'old-lead-session', + members: [{ name: 'team-lead', agentType: 'team-lead', providerId: 'anthropic' }], + }, + null, + 2 + )}\n`, + 'utf8' + ); + + const { svc } = createSafeLaunchService(); + await (svc as any).updateConfigPostLaunch( + teamName, + tempClaudeRoot, + 'new-lead-session', + undefined, + { + providerId: 'codex', + model: 'gpt-5.4', + effort: 'medium', + members: [ + { + name: 'alice', + role: 'Reviewer', + providerId: 'codex', + model: 'gpt-5.4-mini', + }, + { + name: 'bob', + role: 'Developer', + providerId: 'opencode', + model: 'openrouter/google/gemini-2.5-flash', + }, + { + name: 'jack', + role: 'Developer', + workflow: 'Work in the isolated checkout.', + providerId: 'opencode', + model: 'openrouter/qwen/qwen3-coder', + isolation: 'worktree', + cwd: jackWorktree, + }, + ], + } + ); + + const config = JSON.parse(fs.readFileSync(path.join(teamDir, 'config.json'), 'utf8')) as { + leadSessionId?: string; + projectPath?: string; + members: Array<{ + name: string; + agentId?: string; + agentType?: string; + providerId?: string; + model?: string; + role?: string; + workflow?: string; + isolation?: string; + cwd?: string; + }>; + }; + + expect(config.leadSessionId).toBe('new-lead-session'); + expect(config.projectPath).toBe(tempClaudeRoot); + expect(config.members).toEqual([ + expect.objectContaining({ + name: 'team-lead', + providerId: 'codex', + model: 'gpt-5.4', + }), + expect.objectContaining({ + name: 'bob', + agentId: `bob@${teamName}`, + agentType: 'general-purpose', + role: 'Developer', + providerId: 'opencode', + model: 'openrouter/google/gemini-2.5-flash', + }), + expect.objectContaining({ + name: 'jack', + agentId: `jack@${teamName}`, + agentType: 'general-purpose', + role: 'Developer', + workflow: 'Work in the isolated checkout.', + providerId: 'opencode', + model: 'openrouter/qwen/qwen3-coder', + isolation: 'worktree', + cwd: jackWorktree, + }), + ]); + expect(config.members.some((member) => member.name === 'alice')).toBe(false); + }); + it('launches isolated OpenCode side lanes from the resolved member worktree cwd', async () => { allowConsoleLogs(); vi.mocked(ClaudeBinaryResolver.resolve).mockResolvedValue('/mock/claude'); @@ -7721,7 +8195,9 @@ describe('TeamProvisioningService', () => { }; }); - const { svc, membersMetaStore } = createSafeLaunchService({ memberWorktreeManager: worktreeManager }); + const { svc, membersMetaStore } = createSafeLaunchService({ + memberWorktreeManager: worktreeManager, + }); svc.setRuntimeAdapterRegistry( new TeamRuntimeAdapterRegistry([ { @@ -9659,6 +10135,95 @@ describe('TeamProvisioningService', () => { }); }); + it('clears registered-only stale failure when a verified runtime process appears later', async () => { + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'tom', + { + alive: true, + model: 'gpt-5.4', + livenessKind: 'runtime_process', + runtimeDiagnostic: 'verified runtime process detected', + }, + ], + ]) + ); + + const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('forge-labs-10', { + tom: createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + error: 'registered runtime metadata without live process', + hardFailure: true, + hardFailureReason: 'registered runtime metadata without live process', + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + }), + }); + + expect(result.tom).toMatchObject({ + status: 'online', + launchState: 'runtime_pending_bootstrap', + runtimeAlive: true, + hardFailure: false, + hardFailureReason: undefined, + error: undefined, + runtimeModel: 'gpt-5.4', + livenessKind: 'runtime_process', + runtimeDiagnostic: 'verified runtime process detected', + livenessSource: 'process', + }); + }); + + it('does not clear OpenCode bridge launch failure from process-only liveness', async () => { + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( + async () => + new Map([ + [ + 'bob', + { + alive: false, + model: 'openrouter/google/gemini-2.5-flash', + livenessKind: 'runtime_process_candidate', + providerId: 'opencode', + runtimeDiagnostic: + 'OpenCode runtime process detected, but teammate bootstrap is not confirmed', + runtimeDiagnosticSeverity: 'warning', + }, + ], + ]) + ); + + const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('12vector-room-10', { + bob: createMemberSpawnStatusEntry({ + status: 'error', + launchState: 'failed_to_start', + error: 'OpenCode bridge reported member launch failure', + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + }), + }); + + expect(result.bob).toMatchObject({ + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + error: 'OpenCode bridge reported member launch failure', + runtimeModel: 'openrouter/google/gemini-2.5-flash', + livenessKind: 'runtime_process_candidate', + runtimeDiagnostic: + 'OpenCode runtime process detected, but teammate bootstrap is not confirmed', + runtimeDiagnosticSeverity: 'warning', + livenessSource: undefined, + }); + }); + it('maps suffixed live runtime metadata keys back onto canonical spawn statuses', async () => { const svc = new TeamProvisioningService(); (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn( @@ -10301,6 +10866,85 @@ describe('TeamProvisioningService', () => { }); }); + it('confirms a teammate from bootstrap transcript stored under its worktree cwd', async () => { + const teamName = 'worktree-bootstrap-transcript-team'; + const leadSessionId = 'lead-session'; + const projectPath = '/Users/test/proj'; + const worktreePath = `${projectPath}/.claude/worktrees/team-${teamName}-tom-12345678`; + const acceptedAt = new Date(Date.now() - 90_000).toISOString(); + const observedAt = new Date(Date.now() - 30_000).toISOString(); + const teamDir = path.join(tempTeamsBase, teamName); + fs.mkdirSync(teamDir, { recursive: true }); + fs.writeFileSync( + path.join(teamDir, 'config.json'), + JSON.stringify({ + name: teamName, + projectPath, + leadSessionId, + members: [ + { name: 'team-lead', agentType: 'team-lead', cwd: projectPath }, + { name: 'tom', providerId: 'codex', model: 'gpt-5.4', cwd: worktreePath }, + { name: 'bob', providerId: 'codex', model: 'gpt-5.4-mini', cwd: projectPath }, + ], + }), + 'utf8' + ); + writeLaunchState(teamName, leadSessionId, { + tom: { + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'registered runtime metadata without live process', + firstSpawnAcceptedAt: acceptedAt, + lastEvaluatedAt: acceptedAt, + }, + }); + const worktreeProjectDir = path.join(tempProjectsBase, encodePath(worktreePath)); + fs.mkdirSync(worktreeProjectDir, { recursive: true }); + fs.writeFileSync( + path.join(worktreeProjectDir, 'tom-session.jsonl'), + `${JSON.stringify({ + type: 'user', + teamName, + agentName: 'tom', + timestamp: observedAt, + cwd: worktreePath, + message: { + role: 'user', + content: [ + { + type: 'tool_result', + tool_use_id: 'item_0', + content: `Member briefing for tom on team "${teamName}" (${teamName}).\nRole: developer.`, + }, + ], + }, + })}\n`, + 'utf8' + ); + + const svc = new TeamProvisioningService(); + (svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map()); + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(result.statuses.tom).toMatchObject({ + status: 'online', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: true, + hardFailure: false, + hardFailureReason: undefined, + lastHeartbeatAt: observedAt, + }); + }); + it('treats suffixed persisted heartbeat senders as the expected member during reconcile', async () => { const teamName = 'suffixed-heartbeat-reconcile-team'; const svc = new TeamProvisioningService(); @@ -10635,6 +11279,152 @@ describe('TeamProvisioningService', () => { }); }); + it('recovers missing mixed secondary lane index from materialized OpenCode runtime evidence', async () => { + const teamName = 'relay-works-missing-lane-recovery'; + writeTeamMeta(teamName, { + providerId: 'codex', + providerBackendId: 'codex-native', + model: 'gpt-5.4', + }); + writeMembersMeta(teamName, [ + { + name: 'atlas', + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + }, + { + name: 'bob', + providerId: 'codex', + model: 'gpt-5.4', + }, + ]); + writeLaunchConfig(teamName, '/Users/test/proj', 'lead-session', ['bob']); + writeBootstrapState(teamName, [{ name: 'bob', status: 'registered' }]); + fs.writeFileSync( + getTeamLaunchStatePath(teamName), + `${JSON.stringify( + { + version: 2, + teamName, + updatedAt: '2026-04-23T10:00:00.000Z', + expectedMembers: ['atlas', 'bob'], + bootstrapExpectedMembers: ['bob'], + leadSessionId: 'lead-session', + launchPhase: 'reconciled', + members: { + atlas: { + name: 'atlas', + providerId: 'opencode', + model: 'opencode/nemotron-3-super-free', + laneId: 'secondary:opencode:atlas', + laneKind: 'secondary', + laneOwnerProviderId: 'opencode', + launchState: 'failed_to_start', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'OpenCode bridge reported member launch failure', + runtimePid: 44123, + runtimeSessionId: 'ses_atlas_materialized', + livenessKind: 'runtime_process_candidate', + pidSource: 'opencode_bridge', + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + bob: { + name: 'bob', + providerId: 'codex', + laneId: 'primary', + laneKind: 'primary', + laneOwnerProviderId: 'codex', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + lastEvaluatedAt: '2026-04-23T10:00:00.000Z', + }, + }, + summary: { + confirmedCount: 1, + pendingCount: 0, + failedCount: 1, + runtimeAlivePendingCount: 0, + }, + teamLaunchState: 'partial_failure', + }, + null, + 2 + )}\n`, + 'utf8' + ); + + const adapterReconcile = vi.fn(async (input: Record) => { + const member = (input.expectedMembers as Array<{ name: string }>)[0]?.name; + return { + runId: String(input.runId), + teamName, + launchPhase: 'reconciled', + teamLaunchState: 'partial_pending', + members: member + ? { + [member]: { + memberName: member, + providerId: 'opencode', + launchState: 'runtime_pending_bootstrap', + agentToolAccepted: true, + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: false, + runtimePid: 44123, + sessionId: 'ses_atlas_materialized', + livenessKind: 'runtime_process_candidate', + diagnostics: ['runtime process candidate recovered'], + }, + } + : {}, + snapshot: null, + warnings: [], + diagnostics: ['fake reconcile recovered materialized runtime'], + }; + }); + const svc = new TeamProvisioningService(); + svc.setRuntimeAdapterRegistry( + new TeamRuntimeAdapterRegistry([ + { + providerId: 'opencode', + prepare: vi.fn(), + launch: vi.fn(), + reconcile: adapterReconcile, + stop: vi.fn(), + } as any, + ]) + ); + + const result = await svc.getMemberSpawnStatuses(teamName); + + expect(adapterReconcile).toHaveBeenCalledTimes(1); + expect(adapterReconcile).toHaveBeenCalledWith( + expect.objectContaining({ + teamName, + laneId: 'secondary:opencode:atlas', + reason: 'startup_recovery', + }) + ); + expect(result.expectedMembers).toEqual(expect.arrayContaining(['atlas', 'bob'])); + expect(result.statuses.atlas).toMatchObject({ + status: 'waiting', + launchState: 'runtime_pending_bootstrap', + }); + await expect(readOpenCodeRuntimeLaneIndex(tempTeamsBase, teamName)).resolves.toMatchObject({ + lanes: { + 'secondary:opencode:atlas': { + state: 'active', + }, + }, + }); + }); + it('reconciles stale persisted mixed pending OpenCode lanes instead of keeping them pending forever', async () => { const teamName = 'signal-ops-7'; writeTeamMeta(teamName, { diff --git a/test/main/services/team/TeamProvisioningServicePrepare.test.ts b/test/main/services/team/TeamProvisioningServicePrepare.test.ts index 0990bf71..e19e0b9b 100644 --- a/test/main/services/team/TeamProvisioningServicePrepare.test.ts +++ b/test/main/services/team/TeamProvisioningServicePrepare.test.ts @@ -1736,6 +1736,97 @@ describe('TeamProvisioningService prepare/auth behavior', () => { expect(getCodexModelCatalog).toHaveBeenCalledWith({ cwd: tempRoot }); }); + it('uses the orchestrator Codex catalog before falling back to the direct app-server catalog', async () => { + const svc = new TeamProvisioningService(); + vi.spyOn(svc as any, 'buildProvisioningEnv').mockResolvedValue({ + env: { + PATH: '/usr/bin', + SHELL: '/bin/zsh', + }, + authSource: 'codex_runtime', + geminiRuntimeAuth: null, + providerArgs: ['--settings', '{"codex":{"forced_login_method":"chatgpt"}}'], + }); + const getCodexModelCatalog = vi + .spyOn(ProviderConnectionService.getInstance(), 'getCodexModelCatalog') + .mockResolvedValue(null); + + execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => { + if (args.includes('model') && args.includes('list')) { + return { + stdout: JSON.stringify({ + schemaVersion: 1, + providers: { + codex: { + defaultModel: 'gpt-5.4', + models: [{ id: 'gpt-5.4', label: 'GPT-5.4' }], + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + if (args.includes('runtime') && args.includes('status')) { + return { + stdout: JSON.stringify({ + providers: { + codex: { + runtimeCapabilities: { + modelCatalog: { dynamic: true, source: 'app-server' }, + }, + modelCatalog: { + schemaVersion: 1, + providerId: 'codex', + source: 'app-server', + status: 'ready', + fetchedAt: '2026-04-28T00:00:00.000Z', + staleAt: '2026-04-28T00:10:00.000Z', + defaultModelId: 'gpt-5.4', + defaultLaunchModel: 'gpt-5.4', + models: [ + { + id: 'gpt-5.5', + launchModel: 'gpt-5.5', + displayName: 'GPT-5.5', + hidden: false, + supportedReasoningEfforts: ['low', 'medium', 'high', 'xhigh'], + defaultReasoningEffort: 'high', + inputModalities: ['text', 'image'], + supportsPersonality: false, + isDefault: false, + upgrade: false, + source: 'app-server', + }, + ], + diagnostics: { + configReadState: 'skipped', + appServerState: 'healthy', + }, + }, + }, + }, + }), + stderr: '', + exitCode: 0, + }; + } + return { stdout: '', stderr: '', exitCode: 0 }; + }); + + const result = await (svc as any).verifySelectedProviderModels({ + claudePath: '/fake/claude', + cwd: tempRoot, + providerId: 'codex', + modelIds: ['gpt-5.5'], + limitContext: false, + }); + + expect(result.details).toEqual(['Selected model gpt-5.5 is available for launch.']); + expect(result.blockingMessages).toEqual([]); + expect(getCodexModelCatalog).not.toHaveBeenCalled(); + }); + it('passes provider launch args before model-list catalog subcommands', async () => { execCliMock.mockImplementation(async (_binaryPath: string | null, args: string[]) => { if (args.includes('model')) { diff --git a/test/main/services/team/TeamRuntimeLivenessResolver.test.ts b/test/main/services/team/TeamRuntimeLivenessResolver.test.ts index adaf4956..73c91c20 100644 --- a/test/main/services/team/TeamRuntimeLivenessResolver.test.ts +++ b/test/main/services/team/TeamRuntimeLivenessResolver.test.ts @@ -104,7 +104,7 @@ describe('resolveTeamMemberRuntimeLiveness', () => { expect(result.pid).toBe(301); }); - it('promotes a live OpenCode runtime pid only when process identity matches', () => { + it('keeps a live OpenCode runtime pid as candidate until bootstrap is confirmed', () => { const result = resolveTeamMemberRuntimeLiveness({ teamName: 'demo', memberName: 'bob', @@ -116,6 +116,36 @@ describe('resolveTeamMemberRuntimeLiveness', () => { nowIso: NOW, }); + expect(result.alive).toBe(false); + expect(result.livenessKind).toBe('runtime_process_candidate'); + expect(result.pidSource).toBe('opencode_bridge'); + expect(result.pid).toBe(404); + expect(result.runtimeDiagnostic).toBe( + 'OpenCode runtime process detected, but teammate bootstrap is not confirmed' + ); + }); + + it('promotes a live OpenCode runtime pid after bootstrap confirmation', () => { + const result = resolveTeamMemberRuntimeLiveness({ + teamName: 'demo', + memberName: 'bob', + providerId: 'opencode', + persistedRuntimePid: 404, + persistedRuntimeSessionId: 'session-bob', + trackedSpawnStatus: { + status: 'online', + launchState: 'confirmed_alive', + agentToolAccepted: true, + runtimeAlive: true, + bootstrapConfirmed: true, + hardFailure: false, + updatedAt: NOW, + }, + processRows: [{ pid: 404, ppid: 1, command: 'opencode runtime host' }], + processTableAvailable: true, + nowIso: NOW, + }); + expect(result.alive).toBe(true); expect(result.livenessKind).toBe('runtime_process'); expect(result.pidSource).toBe('opencode_bridge'); diff --git a/test/main/services/team/fileLock.test.ts b/test/main/services/team/fileLock.test.ts index 9896e0b3..1e745ab8 100644 --- a/test/main/services/team/fileLock.test.ts +++ b/test/main/services/team/fileLock.test.ts @@ -1,7 +1,7 @@ import * as fs from 'fs'; import * as os from 'os'; import * as path from 'path'; -import { afterEach, beforeEach, describe, expect, it } from 'vitest'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { withFileLock } from '@main/services/team/fileLock'; @@ -67,6 +67,29 @@ describe('withFileLock', () => { expect(result).toBe('ok'); }); + it('removes a fresh abandoned lock when the owner process is gone', async () => { + const lockPath = `${testFile}.lock`; + const abandonedPid = 424_242; + fs.writeFileSync(lockPath, `${abandonedPid}\n${Date.now()}\n`, 'utf8'); + const killSpy = vi.spyOn(process, 'kill').mockImplementation(((pid: number | string) => { + if (pid === abandonedPid) { + const error = new Error('process is gone') as NodeJS.ErrnoException; + error.code = 'ESRCH'; + throw error; + } + return true; + }) as typeof process.kill); + + try { + const result = await withFileLock(testFile, async () => 'ok'); + + expect(result).toBe('ok'); + expect(fs.existsSync(lockPath)).toBe(false); + } finally { + killSpy.mockRestore(); + } + }); + it('creates parent directories for lock file', async () => { const nested = path.join(tmpDir, 'a', 'b', 'deep.json'); diff --git a/test/main/services/team/progressPayload.test.ts b/test/main/services/team/progressPayload.test.ts index 3befc313..46aeb729 100644 --- a/test/main/services/team/progressPayload.test.ts +++ b/test/main/services/team/progressPayload.test.ts @@ -3,9 +3,13 @@ import { describe, expect, it } from 'vitest'; import { PROGRESS_LOG_TAIL_LINES, PROGRESS_OUTPUT_TAIL_PARTS, + PROGRESS_TRACE_TAIL_LINES, boundLaunchDiagnostics, buildProgressAssistantOutput, + buildProgressLiveOutput, buildProgressLogsTail, + buildProgressTraceLine, + buildProgressTraceTail, } from '../../../../src/main/services/team/progressPayload'; describe('buildProgressLogsTail', () => { @@ -77,6 +81,60 @@ describe('buildProgressAssistantOutput', () => { }); }); +describe('buildProgressTraceLine', () => { + it('redacts secrets and strips markdown fence delimiters', () => { + const result = buildProgressTraceLine({ + timestamp: '2026-04-28T12:00:00.000Z', + state: 'spawning', + message: 'Starting runtime --api-key sk-test', + detail: 'OPENAI_API_KEY=super-secret CODEX_API_KEY="also-secret" ```', + }); + + expect(result).toContain('--api-key [redacted]'); + expect(result).toContain('OPENAI_API_KEY=[redacted]'); + expect(result).toContain('CODEX_API_KEY=[redacted]'); + expect(result).not.toContain('sk-test'); + expect(result).not.toContain('super-secret'); + expect(result).not.toContain('also-secret'); + expect(result).not.toContain('```'); + }); +}); + +describe('buildProgressTraceTail', () => { + it('caps trace output to the last N lines', () => { + const lines = Array.from({ length: 10 }, (_, i) => `trace-${i}`); + + expect(buildProgressTraceTail(lines, 3)).toBe('trace-7\ntrace-8\ntrace-9'); + }); + + it('uses the default trace tail size when not overridden', () => { + const lines = Array.from({ length: PROGRESS_TRACE_TAIL_LINES + 10 }, (_, i) => `trace-${i}`); + const result = buildProgressTraceTail(lines); + + expect(result).toBeDefined(); + expect(result!.split('\n')).toHaveLength(PROGRESS_TRACE_TAIL_LINES); + }); +}); + +describe('buildProgressLiveOutput', () => { + it('preserves assistant-only output when no trace is available', () => { + expect(buildProgressLiveOutput([], ['hello'], { maxAssistantParts: 10 })).toBe('hello'); + }); + + it('combines bounded launch trace with runtime output', () => { + const result = buildProgressLiveOutput(['trace-1', 'trace-2'], ['assistant'], { + maxTraceLines: 1, + maxAssistantParts: 10, + }); + + expect(result).toContain('**Launch trace**'); + expect(result).not.toContain('trace-1'); + expect(result).toContain('trace-2'); + expect(result).toContain('**Runtime output**'); + expect(result).toContain('assistant'); + }); +}); + describe('boundLaunchDiagnostics', () => { it('redacts secret CLI flags and caps diagnostic payload size', () => { const longDetail = `node runtime --token super-secret ${'x'.repeat(800)}`; diff --git a/test/main/services/team/runtimeTeammateMode.test.ts b/test/main/services/team/runtimeTeammateMode.test.ts index 61bae905..74313147 100644 --- a/test/main/services/team/runtimeTeammateMode.test.ts +++ b/test/main/services/team/runtimeTeammateMode.test.ts @@ -23,15 +23,65 @@ describe('runtimeTeammateMode', () => { expect(decision.injectedTeammateMode).toBe('tmux'); }); - it('keeps fallback mode when tmux runtime is not ready', async () => { + it('uses native process teammates when tmux runtime is not ready', async () => { mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(false); const { resolveDesktopTeammateModeDecision } = await import('@main/services/team/runtimeTeammateMode'); const decision = await resolveDesktopTeammateModeDecision(undefined); + expect(decision.forceProcessTeammates).toBe(true); + expect(decision.injectedTeammateMode).toBeNull(); + }); + + it('treats explicit auto mode as automatic process teammate selection without injection', async () => { + mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true); + const { resolveDesktopTeammateModeDecision } = + await import('@main/services/team/runtimeTeammateMode'); + + const decision = await resolveDesktopTeammateModeDecision('--teammate-mode auto'); + const equalsDecision = await resolveDesktopTeammateModeDecision('--teammate-mode=auto'); + + expect(decision.forceProcessTeammates).toBe(true); + expect(decision.injectedTeammateMode).toBeNull(); + expect(equalsDecision.forceProcessTeammates).toBe(true); + expect(equalsDecision.injectedTeammateMode).toBeNull(); + expect(mockIsTmuxRuntimeReadyForCurrentPlatform).not.toHaveBeenCalled(); + }); + + it('honors explicit in-process mode as an opt-out from process teammates', async () => { + mockIsTmuxRuntimeReadyForCurrentPlatform.mockResolvedValue(true); + const { resolveDesktopTeammateModeDecision } = + await import('@main/services/team/runtimeTeammateMode'); + + const decision = await resolveDesktopTeammateModeDecision('--teammate-mode=in-process'); + expect(decision.forceProcessTeammates).toBe(false); expect(decision.injectedTeammateMode).toBeNull(); + expect(mockIsTmuxRuntimeReadyForCurrentPlatform).not.toHaveBeenCalled(); + }); + + it('removes inherited process fallback env when explicit in-process mode opts out', async () => { + const { applyDesktopTeammateModeDecisionToEnv } = + await import('@main/services/team/runtimeTeammateMode'); + const env = { + CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES: '1', + }; + + applyDesktopTeammateModeDecisionToEnv(env, { forceProcessTeammates: false }); + + expect(env).not.toHaveProperty('CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES'); + }); + + it('builds injected teammate mode cli args only when a mode was selected', async () => { + const { buildDesktopTeammateModeCliArgs } = + await import('@main/services/team/runtimeTeammateMode'); + + expect(buildDesktopTeammateModeCliArgs({ injectedTeammateMode: 'tmux' })).toEqual([ + '--teammate-mode', + 'tmux', + ]); + expect(buildDesktopTeammateModeCliArgs({ injectedTeammateMode: null })).toEqual([]); }); it('re-checks tmux readiness after the environment changes instead of keeping a stale negative cache', async () => { @@ -44,7 +94,7 @@ describe('runtimeTeammateMode', () => { const firstDecision = await resolveDesktopTeammateModeDecision(undefined); const secondDecision = await resolveDesktopTeammateModeDecision(undefined); - expect(firstDecision.forceProcessTeammates).toBe(false); + expect(firstDecision.forceProcessTeammates).toBe(true); expect(firstDecision.injectedTeammateMode).toBeNull(); expect(secondDecision.forceProcessTeammates).toBe(true); expect(secondDecision.injectedTeammateMode).toBe('tmux'); diff --git a/test/main/services/team/stallMonitor/OpenCodeTaskStallEvidenceSource.test.ts b/test/main/services/team/stallMonitor/OpenCodeTaskStallEvidenceSource.test.ts new file mode 100644 index 00000000..86306aeb --- /dev/null +++ b/test/main/services/team/stallMonitor/OpenCodeTaskStallEvidenceSource.test.ts @@ -0,0 +1,148 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { OpenCodeTaskStallEvidenceSource } from '../../../../../src/main/services/team/stallMonitor/OpenCodeTaskStallEvidenceSource'; + +import type { OpenCodeRuntimeTranscriptLogMessage } from '../../../../../src/main/services/runtime/ClaudeMultimodelBridgeService'; +import type { TeamTask } from '../../../../../src/shared/types'; + +function createMessage( + overrides: Partial +): OpenCodeRuntimeTranscriptLogMessage { + return { + uuid: 'msg-1', + parentUuid: null, + type: 'assistant', + timestamp: '2026-04-19T12:00:00.000Z', + content: '', + isMeta: false, + sessionId: 'session-open', + toolCalls: [], + toolResults: [], + ...overrides, + }; +} + +describe('OpenCodeTaskStallEvidenceSource', () => { + it('projects OpenCode task marker tools into stall records and exact rows', async () => { + const task: TeamTask = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + owner: 'bob', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T11:55:00.000Z' }], + }; + const runtimeBridge = { + getOpenCodeTranscript: vi.fn(async () => ({ + sessionId: 'session-open', + logProjection: { + messages: [ + createMessage({ + uuid: 'msg-native', + timestamp: '2026-04-19T11:59:00.000Z', + toolCalls: [ + { + id: 'tool-read', + name: 'read', + input: { filePath: '/tmp/a.ts' }, + isTask: false, + }, + ], + }), + createMessage({ + uuid: 'msg-start', + timestamp: '2026-04-19T12:00:00.000Z', + toolCalls: [ + { + id: 'tool-start', + name: 'agent-teams_task_start', + input: { teamName: 'demo', taskId: 'task-a' }, + isTask: false, + }, + ], + }), + createMessage({ + uuid: 'msg-foreign', + timestamp: '2026-04-19T12:01:00.000Z', + toolCalls: [ + { + id: 'tool-foreign', + name: 'agent-teams_task_start', + input: { teamName: 'other-team', taskId: 'task-a' }, + isTask: false, + }, + ], + }), + ], + }, + })), + }; + const source = new OpenCodeTaskStallEvidenceSource( + runtimeBridge as never, + { resolve: vi.fn(async () => '/tmp/orchestrator-cli') } + ); + + const evidence = await source.readEvidence({ + teamName: 'demo', + tasks: [task], + providerByMemberName: new Map([['bob', 'opencode']]), + }); + + expect(runtimeBridge.getOpenCodeTranscript).toHaveBeenCalledWith('/tmp/orchestrator-cli', { + teamId: 'demo', + memberName: 'bob', + limit: 500, + }); + expect(evidence.recordsByTaskId.get('task-a')).toHaveLength(1); + expect(evidence.recordsByTaskId.get('task-a')?.[0]).toMatchObject({ + timestamp: '2026-04-19T12:00:00.000Z', + actor: { + memberName: 'bob', + role: 'member', + sessionId: 'session-open', + }, + action: { + canonicalToolName: 'task_start', + toolUseId: 'tool-start', + }, + }); + const exactRows = [...evidence.exactRowsByFilePath.values()][0] ?? []; + expect(exactRows.map((row) => row.messageUuid)).toEqual([ + 'msg-native', + 'msg-start', + 'msg-foreign', + ]); + expect(exactRows[0]?.toolUseIds).toEqual(['tool-read']); + }); + + it('does not call OpenCode when no task owner is an OpenCode member', async () => { + const runtimeBridge = { + getOpenCodeTranscript: vi.fn(), + }; + const binaryResolver = { + resolve: vi.fn(async () => '/tmp/orchestrator-cli'), + }; + const source = new OpenCodeTaskStallEvidenceSource( + runtimeBridge as never, + binaryResolver + ); + + const evidence = await source.readEvidence({ + teamName: 'demo', + tasks: [ + { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + owner: 'alice', + status: 'in_progress', + }, + ], + providerByMemberName: new Map([['alice', 'codex']]), + }); + + expect(binaryResolver.resolve).not.toHaveBeenCalled(); + expect(runtimeBridge.getOpenCodeTranscript).not.toHaveBeenCalled(); + expect(evidence.recordsByTaskId.size).toBe(0); + }); +}); diff --git a/test/main/services/team/stallMonitor/TaskProgressSignalClassifier.test.ts b/test/main/services/team/stallMonitor/TaskProgressSignalClassifier.test.ts new file mode 100644 index 00000000..8fc0c323 --- /dev/null +++ b/test/main/services/team/stallMonitor/TaskProgressSignalClassifier.test.ts @@ -0,0 +1,125 @@ +import { describe, expect, it } from 'vitest'; + +import { + classifyTaskProgressTouch, + getTaskCommentForActivityRecord, +} from '../../../../../src/main/services/team/stallMonitor/TaskProgressSignalClassifier'; + +import type { BoardTaskActivityRecord } from '../../../../../src/main/services/team/taskLogs/activity/BoardTaskActivityRecord'; +import type { TeamTask } from '../../../../../src/shared/types'; + +function createTask(commentText?: string): TeamTask { + return { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + status: 'in_progress', + comments: + commentText == null + ? [] + : [ + { + id: 'comment-a', + author: 'alice', + text: commentText, + createdAt: '2026-04-19T12:00:00.000Z', + type: 'regular', + }, + ], + }; +} + +function createCommentRecord(commentId: string | null = 'comment-a'): BoardTaskActivityRecord { + return { + id: 'record-a', + timestamp: '2026-04-19T12:00:00.000Z', + task: { + locator: { ref: 'task-a', refKind: 'canonical', canonicalId: 'task-a' }, + resolution: 'resolved', + taskRef: { taskId: 'task-a', displayId: 'abcd1234', teamName: 'demo' }, + }, + linkKind: 'board_action', + targetRole: 'subject', + actor: { + memberName: 'alice', + role: 'member', + sessionId: 'session-a', + isSidechain: true, + }, + actorContext: { relation: 'same_task' }, + action: { + canonicalToolName: 'task_add_comment', + category: 'comment', + toolUseId: 'tool-a', + details: commentId ? { commentId } : {}, + }, + source: { + messageUuid: 'msg-a', + filePath: '/tmp/session.jsonl', + toolUseId: 'tool-a', + sourceOrder: 1, + }, + }; +} + +describe('TaskProgressSignalClassifier', () => { + it.each([ + 'ΠΠ°Ρ‡ΠΈΠ½Π°ΡŽ Ρ€Π°Π±ΠΎΡ‚Ρƒ.', + 'ΠŸΡ€ΠΈΡΡ‚ΡƒΠΏΠ°ΡŽ.', + 'Π‘Π΅Ρ€Ρƒ Π² Ρ€Π°Π±ΠΎΡ‚Ρƒ.', + 'ΠŸΡ€ΠΎΠ²Π΅Ρ€ΡŽ.', + 'ΠŸΠΎΡΠΌΠΎΡ‚Ρ€ΡŽ.', + 'Will start.', + 'Starting work.', + 'Taking this.', + ])( + 'classifies start-only comment as weak: %s', + (text) => { + expect( + classifyTaskProgressTouch({ + task: createTask(text), + record: createCommentRecord(), + }) + ).toMatchObject({ signal: 'weak_start_only' }); + } + ); + + it.each([ + 'Found the failing test in src/app.ts and reproduced it with pnpm test.', + 'ΠŸΡ€ΠΎΠ²Π΅Ρ€ΠΈΠ» src/main.ts - ΠΏΡ€ΠΈΡ‡ΠΈΠ½Π° Π² stale runtime metadata.', + 'Blocked: Π½Π΅Ρ‚ доступа ΠΊ ΠΏΡ€ΠΎΠ΅ΠΊΡ‚Ρƒ.', + 'НуТно ΡƒΡ‚ΠΎΡ‡Π½Π΅Π½ΠΈΠ΅: ΠΊΠ°ΠΊΠΎΠΉ Ρ„Π°ΠΉΠ» ΠΌΠ΅Π½ΡΡ‚ΡŒ?', + 'Tests failed with EADDRINUSE, next step is to isolate the server port.', + ])('does not classify substantive, blocker, or question comments as weak: %s', (text) => { + const classification = classifyTaskProgressTouch({ + task: createTask(text), + record: createCommentRecord(), + }); + + expect(classification.signal).not.toBe('weak_start_only'); + }); + + it('returns unknown when commentId is missing', () => { + expect( + classifyTaskProgressTouch({ + task: createTask('ΠΠ°Ρ‡ΠΈΠ½Π°ΡŽ Ρ€Π°Π±ΠΎΡ‚Ρƒ.'), + record: createCommentRecord(null), + }) + ).toMatchObject({ signal: 'unknown' }); + }); + + it('returns unknown when comment text is unavailable', () => { + expect( + classifyTaskProgressTouch({ + task: createTask(), + record: createCommentRecord(), + }) + ).toMatchObject({ signal: 'unknown' }); + }); + + it('returns the matching task comment for an activity record', () => { + const task = createTask('ΠΠ°Ρ‡ΠΈΠ½Π°ΡŽ Ρ€Π°Π±ΠΎΡ‚Ρƒ.'); + + expect(getTaskCommentForActivityRecord(task, createCommentRecord())?.id).toBe('comment-a'); + }); +}); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallJournal.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallJournal.test.ts index 5fe89983..519f7b28 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallJournal.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallJournal.test.ts @@ -48,4 +48,57 @@ describe('TeamTaskStallJournal', () => { expect(firstReady).toEqual([]); expect(secondReady).toEqual([evaluation]); }); + + it('does not prune journal entries outside an explicit task scope', async () => { + tmpDir = await fs.mkdtemp(path.join(os.tmpdir(), 'stall-journal-')); + setClaudeBasePathOverride(tmpDir); + const teamDir = path.join(tmpDir, 'teams', 'demo'); + await fs.mkdir(teamDir, { recursive: true }); + const journalPath = path.join(teamDir, 'stall-monitor-journal.json'); + await fs.writeFile( + journalPath, + JSON.stringify( + [ + { + epochKey: 'task-codex:epoch-1', + teamName: 'demo', + taskId: 'task-codex', + branch: 'work', + signal: 'turn_ended_after_touch', + state: 'suspected', + consecutiveScans: 1, + createdAt: '2026-04-19T12:00:00.000Z', + updatedAt: '2026-04-19T12:00:00.000Z', + }, + { + epochKey: 'task-opencode:epoch-1', + teamName: 'demo', + taskId: 'task-opencode', + branch: 'work', + signal: 'turn_ended_after_touch', + state: 'suspected', + consecutiveScans: 1, + createdAt: '2026-04-19T12:00:00.000Z', + updatedAt: '2026-04-19T12:00:00.000Z', + }, + ], + null, + 2 + ) + ); + + const journal = new TeamTaskStallJournal(); + await journal.reconcileScan({ + teamName: 'demo', + evaluations: [], + activeTaskIds: ['task-codex', 'task-opencode'], + scopeTaskIds: ['task-opencode'], + now: '2026-04-19T12:10:00.000Z', + }); + + const saved = JSON.parse(await fs.readFile(journalPath, 'utf8')) as Array<{ + epochKey: string; + }>; + expect(saved.map((entry) => entry.epochKey)).toEqual(['task-codex:epoch-1']); + }); }); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts index 808696f4..82209636 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallMonitor.test.ts @@ -8,10 +8,37 @@ describe('TeamTaskStallMonitor', () => { vi.unstubAllEnvs(); }); - it('runs end-to-end and notifies only after a second confirmed scan', async () => { + it('does not start scans or track team events when scanner gates are explicitly disabled', () => { + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'false'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'false'); + + const registry = { + start: vi.fn(), + stop: vi.fn(async () => undefined), + noteTeamChange: vi.fn(), + listActiveTeams: vi.fn(async () => []), + }; + const monitor = new TeamTaskStallMonitor( + registry as never, + { getSnapshot: vi.fn() } as never, + { evaluateWork: vi.fn(), evaluateReview: vi.fn() } as never, + { reconcileScan: vi.fn(), markAlerted: vi.fn() } as never, + { notifyLead: vi.fn(), notifyOpenCodeOwners: vi.fn() } as never + ); + + monitor.start(); + monitor.noteTeamChange({ + type: 'lead-activity', + teamName: 'demo', + detail: 'active', + }); + + expect(registry.start).not.toHaveBeenCalled(); + expect(registry.noteTeamChange).not.toHaveBeenCalled(); + }); + + it('defaults to monitoring non-OpenCode work stalls and notifies lead after a second confirmed scan', async () => { vi.useFakeTimers(); - vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'true'); - vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', 'true'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1'); @@ -62,6 +89,7 @@ describe('TeamTaskStallMonitor', () => { }; const notifier = { notifyLead: vi.fn(async () => undefined), + notifyOpenCodeOwners: vi.fn(async () => []), }; const monitor = new TeamTaskStallMonitor( @@ -84,4 +112,283 @@ describe('TeamTaskStallMonitor', () => { expect.any(String) ); }); + + it('defaults to OpenCode owner remediation without duplicate lead alerts when remediation is accepted', async () => { + vi.useFakeTimers(); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1'); + + const task = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + owner: 'alice', + }; + const readyEvaluation = { + status: 'alert', + taskId: 'task-a', + branch: 'work', + signal: 'turn_ended_after_touch', + progressSignal: 'weak_start_only', + epochKey: 'task-a:epoch', + reason: 'Potential work stall after weak start-only task comment.', + }; + const journal = { + reconcileScan: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([readyEvaluation]), + markAlerted: vi.fn(async () => undefined), + }; + const notifier = { + notifyLead: vi.fn(async () => undefined), + notifyOpenCodeOwners: vi.fn(async (_teamName: string, alerts: unknown[]) => alerts), + }; + const monitor = new TeamTaskStallMonitor( + { + start: vi.fn(), + stop: vi.fn(async () => undefined), + noteTeamChange: vi.fn(), + listActiveTeams: vi.fn(async () => ['demo']), + } as never, + { + getSnapshot: vi.fn(async () => ({ + teamName: 'demo', + inProgressTasks: [task], + reviewOpenTasks: [], + allTasksById: new Map([['task-a', task]]), + providerByMemberName: new Map([['alice', 'opencode']]), + })), + } as never, + { + evaluateWork: vi.fn(() => readyEvaluation), + evaluateReview: vi.fn(), + } as never, + journal as never, + notifier as never + ); + + monitor.start(); + await vi.advanceTimersByTimeAsync(2_100); + await vi.advanceTimersByTimeAsync(2_100); + + expect(notifier.notifyOpenCodeOwners).toHaveBeenCalledTimes(1); + expect(notifier.notifyLead).not.toHaveBeenCalled(); + expect(journal.reconcileScan).toHaveBeenLastCalledWith( + expect.not.objectContaining({ + scopeTaskIds: expect.any(Array), + }) + ); + expect(journal.markAlerted).toHaveBeenCalledWith( + 'demo', + 'task-a:epoch', + expect.any(String) + ); + }); + + it('uses OpenCode owner remediation without lead alerts when only remediation is enabled', async () => { + vi.useFakeTimers(); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'false'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', 'false'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1'); + + const registry = { + start: vi.fn(), + stop: vi.fn(async () => undefined), + noteTeamChange: vi.fn(), + listActiveTeams: vi.fn(async () => ['demo']), + }; + const task = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + owner: 'alice', + }; + const snapshot = { + teamName: 'demo', + inProgressTasks: [task], + reviewOpenTasks: [], + allTasksById: new Map([['task-a', task]]), + providerByMemberName: new Map([['alice', 'opencode']]), + }; + const snapshotSource = { + getSnapshot: vi.fn(async () => snapshot), + }; + const readyEvaluation = { + status: 'alert', + taskId: 'task-a', + branch: 'work', + signal: 'turn_ended_after_touch', + progressSignal: 'weak_start_only', + epochKey: 'task-a:epoch', + reason: 'Potential work stall after weak start-only task comment.', + }; + const policy = { + evaluateWork: vi.fn(() => readyEvaluation), + evaluateReview: vi.fn(), + }; + const journal = { + reconcileScan: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([readyEvaluation]), + markAlerted: vi.fn(async () => undefined), + }; + const notifier = { + notifyLead: vi.fn(async () => undefined), + notifyOpenCodeOwners: vi.fn(async (_teamName: string, alerts: unknown[]) => alerts), + }; + + const monitor = new TeamTaskStallMonitor( + registry as never, + snapshotSource as never, + policy as never, + journal as never, + notifier as never + ); + + monitor.start(); + await vi.advanceTimersByTimeAsync(2_100); + await vi.advanceTimersByTimeAsync(2_100); + + expect(notifier.notifyOpenCodeOwners).toHaveBeenCalledTimes(1); + expect(journal.reconcileScan).toHaveBeenLastCalledWith( + expect.objectContaining({ + evaluations: [readyEvaluation], + scopeTaskIds: ['task-a'], + }) + ); + expect(notifier.notifyLead).not.toHaveBeenCalled(); + expect(journal.markAlerted).toHaveBeenCalledWith( + 'demo', + 'task-a:epoch', + expect.any(String) + ); + }); + + it('does not journal non-OpenCode task alerts when only OpenCode remediation is enabled', async () => { + vi.useFakeTimers(); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'false'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', 'false'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1'); + + const task = { + id: 'task-codex', + displayId: 'c0dex123', + subject: 'Codex task', + owner: 'alice', + }; + const readyEvaluation = { + status: 'alert', + taskId: 'task-codex', + branch: 'work', + signal: 'turn_ended_after_touch', + epochKey: 'task-codex:epoch', + reason: 'Potential work stall.', + }; + const journal = { + reconcileScan: vi.fn(async ({ evaluations }: { evaluations: unknown[] }) => evaluations), + markAlerted: vi.fn(async () => undefined), + }; + const notifier = { + notifyLead: vi.fn(async () => undefined), + notifyOpenCodeOwners: vi.fn(async (_teamName: string, alerts: unknown[]) => alerts), + }; + const monitor = new TeamTaskStallMonitor( + { + start: vi.fn(), + stop: vi.fn(async () => undefined), + noteTeamChange: vi.fn(), + listActiveTeams: vi.fn(async () => ['demo']), + } as never, + { + getSnapshot: vi.fn(async () => ({ + teamName: 'demo', + inProgressTasks: [task], + reviewOpenTasks: [], + allTasksById: new Map([['task-codex', task]]), + providerByMemberName: new Map([['alice', 'codex']]), + })), + } as never, + { + evaluateWork: vi.fn(() => readyEvaluation), + evaluateReview: vi.fn(), + } as never, + journal as never, + notifier as never + ); + + monitor.start(); + await vi.advanceTimersByTimeAsync(2_100); + await vi.advanceTimersByTimeAsync(1_100); + + expect(journal.reconcileScan).toHaveBeenCalledWith( + expect.objectContaining({ + evaluations: [], + scopeTaskIds: [], + }) + ); + expect(notifier.notifyOpenCodeOwners).not.toHaveBeenCalled(); + expect(notifier.notifyLead).not.toHaveBeenCalled(); + expect(journal.markAlerted).not.toHaveBeenCalled(); + }); + + it('defaults to lead fallback when OpenCode remediation is not accepted', async () => { + vi.useFakeTimers(); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1000'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '1'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '1'); + + const registry = { + start: vi.fn(), + stop: vi.fn(async () => undefined), + noteTeamChange: vi.fn(), + listActiveTeams: vi.fn(async () => ['demo']), + }; + const task = { + id: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + owner: 'alice', + }; + const snapshot = { + teamName: 'demo', + inProgressTasks: [task], + reviewOpenTasks: [], + allTasksById: new Map([['task-a', task]]), + providerByMemberName: new Map([['alice', 'opencode']]), + }; + const readyEvaluation = { + status: 'alert', + taskId: 'task-a', + branch: 'work', + signal: 'turn_ended_after_touch', + epochKey: 'task-a:epoch', + reason: 'Potential work stall.', + }; + const notifier = { + notifyOpenCodeOwners: vi.fn(async () => []), + notifyLead: vi.fn(async () => undefined), + }; + const monitor = new TeamTaskStallMonitor( + registry as never, + { getSnapshot: vi.fn(async () => snapshot) } as never, + { + evaluateWork: vi.fn(() => readyEvaluation), + evaluateReview: vi.fn(), + } as never, + { + reconcileScan: vi.fn().mockResolvedValueOnce([]).mockResolvedValueOnce([readyEvaluation]), + markAlerted: vi.fn(async () => undefined), + } as never, + notifier as never + ); + + monitor.start(); + await vi.advanceTimersByTimeAsync(2_100); + await vi.advanceTimersByTimeAsync(2_100); + + expect(notifier.notifyLead).toHaveBeenCalledTimes(1); + }); }); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts new file mode 100644 index 00000000..3c792f02 --- /dev/null +++ b/test/main/services/team/stallMonitor/TeamTaskStallNotifier.test.ts @@ -0,0 +1,234 @@ +import { describe, expect, it, vi } from 'vitest'; + +import { TeamTaskStallNotifier } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallNotifier'; + +import type { TaskStallAlert } from '../../../../../src/main/services/team/stallMonitor/TeamTaskStallTypes'; + +function createAlert(overrides: Partial = {}): TaskStallAlert { + return { + teamName: 'demo', + taskId: 'task-a', + displayId: 'abcd1234', + subject: 'Task A', + branch: 'work', + signal: 'turn_ended_after_touch', + progressSignal: 'weak_start_only', + reason: 'Potential work stall after weak start-only task comment.', + epochKey: 'task-a:work:turn_ended_after_touch:stamp:file:msg:tool', + owner: 'alice', + ownerProviderId: 'opencode', + taskRef: { + taskId: 'task-a', + displayId: 'abcd1234', + teamName: 'demo', + }, + ...overrides, + }; +} + +describe('TeamTaskStallNotifier', () => { + it('sends OpenCode owner nudges with deterministic message ids', async () => { + const teamDataService = { + sendSystemNotificationToLead: vi.fn(async () => undefined), + }; + const teamProvisioningService = { + relayOpenCodeMemberInboxMessages: vi.fn(async () => ({ + relayed: 1, + attempted: 1, + delivered: 1, + failed: 0, + lastDelivery: { delivered: true, accepted: true }, + })), + }; + const inboxReader = { + getMessagesFor: vi.fn(async () => []), + }; + const inboxWriter = { + sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })), + }; + const notifier = new TeamTaskStallNotifier( + teamDataService as never, + teamProvisioningService as never, + inboxReader as never, + inboxWriter as never + ); + const alert = createAlert(); + const messageId = `task-stall:demo:task-a:${alert.epochKey}`; + + await expect(notifier.notifyOpenCodeOwners('demo', [alert])).resolves.toEqual([alert]); + + expect(inboxWriter.sendMessage).toHaveBeenCalledWith( + 'demo', + expect.objectContaining({ + member: 'alice', + from: 'system', + to: 'alice', + messageId, + summary: 'Potential stalled task', + taskRefs: [alert.taskRef], + actionMode: 'do', + source: 'system_notification', + }) + ); + expect(teamProvisioningService.relayOpenCodeMemberInboxMessages).toHaveBeenCalledWith( + 'demo', + 'alice', + { + onlyMessageId: messageId, + source: 'watchdog', + deliveryMetadata: { + replyRecipient: 'user', + actionMode: 'do', + taskRefs: [alert.taskRef], + }, + } + ); + expect(teamDataService.sendSystemNotificationToLead).not.toHaveBeenCalled(); + }); + + it('skips non-OpenCode owners', async () => { + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { + relayOpenCodeMemberInboxMessages: vi.fn(async () => ({ + lastDelivery: { delivered: true }, + })), + } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never + ); + + await expect( + notifier.notifyOpenCodeOwners('demo', [ + createAlert({ ownerProviderId: 'codex', owner: 'alice' }), + ]) + ).resolves.toEqual([]); + }); + + it('skips review alerts because task owner is not necessarily the reviewer', async () => { + const relay = vi.fn(async () => ({ lastDelivery: { delivered: true } })); + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { relayOpenCodeMemberInboxMessages: relay } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never + ); + + await expect( + notifier.notifyOpenCodeOwners('demo', [ + createAlert({ branch: 'review', ownerProviderId: 'opencode', owner: 'alice' }), + ]) + ).resolves.toEqual([]); + expect(relay).not.toHaveBeenCalled(); + }); + + it('returns no remediated alert when OpenCode delivery is rejected', async () => { + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { + relayOpenCodeMemberInboxMessages: vi.fn(async () => ({ + relayed: 0, + attempted: 1, + delivered: 0, + failed: 1, + lastDelivery: { + delivered: false, + reason: 'opencode_runtime_not_active', + }, + })), + } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never + ); + + await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]); + }); + + it('does not mark queued-behind delivery as remediated even when active ledger exists', async () => { + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { + relayOpenCodeMemberInboxMessages: vi.fn(async () => ({ + relayed: 0, + attempted: 1, + delivered: 0, + failed: 0, + lastDelivery: { + delivered: true, + accepted: false, + responsePending: true, + ledgerRecordId: 'active-ledger-record', + queuedBehindMessageId: 'msg-active', + reason: 'opencode_delivery_response_pending', + }, + })), + } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never + ); + + await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]); + }); + + it('marks accepted response-pending delivery as remediated and leaves follow-up to the delivery ledger', async () => { + const relay = vi.fn(async () => ({ + relayed: 1, + attempted: 1, + delivered: 1, + failed: 0, + lastDelivery: { + delivered: true, + accepted: true, + responsePending: true, + ledgerRecordId: 'active-ledger-record', + reason: 'opencode_delivery_response_pending', + }, + })); + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { relayOpenCodeMemberInboxMessages: relay } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })) } as never + ); + + const alert = createAlert(); + await expect(notifier.notifyOpenCodeOwners('demo', [alert])).resolves.toEqual([alert]); + expect(relay).toHaveBeenCalledTimes(1); + }); + + it('does not deliver runtime nudge when inbox write fails', async () => { + const relay = vi.fn(async () => ({ lastDelivery: { delivered: true } })); + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { relayOpenCodeMemberInboxMessages: relay } as never, + { getMessagesFor: vi.fn(async () => []) } as never, + { sendMessage: vi.fn(async () => { throw new Error('disk full'); }) } as never + ); + + await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]); + expect(relay).not.toHaveBeenCalled(); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( + 'OpenCode task stall remediation inbox write failed' + ); + vi.mocked(console.warn).mockClear(); + }); + + it('does not write or relay when existing inbox read fails', async () => { + const relay = vi.fn(async () => ({ lastDelivery: { delivered: true } })); + const inboxWrite = vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg' })); + const notifier = new TeamTaskStallNotifier( + { sendSystemNotificationToLead: vi.fn(async () => undefined) } as never, + { relayOpenCodeMemberInboxMessages: relay } as never, + { getMessagesFor: vi.fn(async () => { throw new Error('read failed'); }) } as never, + { sendMessage: inboxWrite } as never + ); + + await expect(notifier.notifyOpenCodeOwners('demo', [createAlert()])).resolves.toEqual([]); + expect(inboxWrite).not.toHaveBeenCalled(); + expect(relay).not.toHaveBeenCalled(); + expect(vi.mocked(console.warn).mock.calls[0]?.join(' ')).toContain( + 'OpenCode task stall remediation inbox write failed' + ); + vi.mocked(console.warn).mockClear(); + }); +}); diff --git a/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts index ce214ad2..8d166bde 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallPolicy.test.ts @@ -97,6 +97,7 @@ function createSnapshot(overrides: Partial): TeamTaskStal recordsByTaskId: new Map(), freshnessByTaskId: new Map(), exactRowsByFilePath: new Map(), + providerByMemberName: new Map(), ...overrides, }; } @@ -155,6 +156,363 @@ describe('TeamTaskStallPolicy', () => { }); }); + it('alerts OpenCode-owned tasks faster after weak start-only task comments', () => { + const task: TeamTask = { + id: 'task-open-weak', + displayId: 'feed1111', + subject: 'OpenCode weak start', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], + comments: [ + { + id: 'comment-weak', + author: 'alice', + text: 'ΠΠ°Ρ‡ΠΈΠ½Π°ΡŽ Ρ€Π°Π±ΠΎΡ‚Ρƒ.', + createdAt: '2026-04-19T12:00:00.000Z', + type: 'regular', + }, + ], + }; + const record = createRecord({ + task: { + locator: { + ref: 'task-open-weak', + refKind: 'canonical', + canonicalId: 'task-open-weak', + }, + resolution: 'resolved', + taskRef: { + taskId: 'task-open-weak', + displayId: 'feed1111', + teamName: 'demo', + }, + }, + action: { + canonicalToolName: 'task_add_comment', + category: 'comment', + toolUseId: 'tool-weak', + details: { commentId: 'comment-weak' }, + }, + source: { + messageUuid: 'msg-touch', + filePath: '/tmp/session.jsonl', + toolUseId: 'tool-weak', + sourceOrder: 1, + }, + }); + const snapshot = createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + providerByMemberName: new Map([['alice', 'opencode']]), + recordsByTaskId: new Map([[task.id, [record]]]), + exactRowsByFilePath: new Map([ + [ + '/tmp/session.jsonl', + [ + createExactRow({ + messageUuid: 'msg-touch', + toolUseIds: ['tool-weak'], + }), + createExactRow({ + sourceOrder: 2, + messageUuid: 'msg-turn-end', + systemSubtype: 'turn_duration', + parsedMessage: createParsedMessage({ + uuid: 'msg-turn-end', + type: 'system', + }), + }), + ], + ], + ]), + }); + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:07:00.000Z'), + task, + snapshot, + }); + + expect(evaluation).toMatchObject({ + status: 'alert', + taskId: 'task-open-weak', + progressSignal: 'weak_start_only', + reason: 'Potential work stall after weak start-only task comment.', + }); + }); + + it('keeps existing thresholds for weak comments from non-OpenCode owners', () => { + const task: TeamTask = { + id: 'task-codex-weak', + displayId: 'feed2222', + subject: 'Codex weak start', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], + comments: [ + { + id: 'comment-weak', + author: 'alice', + text: 'Will start.', + createdAt: '2026-04-19T12:00:00.000Z', + type: 'regular', + }, + ], + }; + const record = createRecord({ + task: { + locator: { + ref: 'task-codex-weak', + refKind: 'canonical', + canonicalId: 'task-codex-weak', + }, + resolution: 'resolved', + taskRef: { + taskId: 'task-codex-weak', + displayId: 'feed2222', + teamName: 'demo', + }, + }, + action: { + canonicalToolName: 'task_add_comment', + category: 'comment', + toolUseId: 'tool-weak', + details: { commentId: 'comment-weak' }, + }, + source: { + messageUuid: 'msg-touch', + filePath: '/tmp/session.jsonl', + toolUseId: 'tool-weak', + sourceOrder: 1, + }, + }); + const snapshot = createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + providerByMemberName: new Map([['alice', 'codex']]), + recordsByTaskId: new Map([[task.id, [record]]]), + exactRowsByFilePath: new Map([ + [ + '/tmp/session.jsonl', + [ + createExactRow({ + messageUuid: 'msg-touch', + toolUseIds: ['tool-weak'], + }), + createExactRow({ + sourceOrder: 2, + messageUuid: 'msg-turn-end', + systemSubtype: 'turn_duration', + parsedMessage: createParsedMessage({ + uuid: 'msg-turn-end', + type: 'system', + }), + }), + ], + ], + ]), + }); + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:07:00.000Z'), + task, + snapshot, + }); + + expect(evaluation).toMatchObject({ + status: 'skip', + taskId: 'task-codex-weak', + skipReason: 'below_threshold', + }); + }); + + it('does not apply weak-start threshold to concrete task comments', () => { + const task: TeamTask = { + id: 'task-open-strong', + displayId: 'feed3333', + subject: 'OpenCode concrete progress', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T11:50:00.000Z' }], + comments: [ + { + id: 'comment-strong', + author: 'alice', + text: 'Found the failing test in src/app.ts and reproduced it with pnpm test.', + createdAt: '2026-04-19T12:00:00.000Z', + type: 'regular', + }, + ], + }; + const record = createRecord({ + task: { + locator: { + ref: 'task-open-strong', + refKind: 'canonical', + canonicalId: 'task-open-strong', + }, + resolution: 'resolved', + taskRef: { + taskId: 'task-open-strong', + displayId: 'feed3333', + teamName: 'demo', + }, + }, + action: { + canonicalToolName: 'task_add_comment', + category: 'comment', + toolUseId: 'tool-strong', + details: { commentId: 'comment-strong' }, + }, + source: { + messageUuid: 'msg-touch', + filePath: '/tmp/session.jsonl', + toolUseId: 'tool-strong', + sourceOrder: 1, + }, + }); + const snapshot = createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + providerByMemberName: new Map([['alice', 'opencode']]), + recordsByTaskId: new Map([[task.id, [record]]]), + exactRowsByFilePath: new Map([ + [ + '/tmp/session.jsonl', + [ + createExactRow({ + messageUuid: 'msg-touch', + toolUseIds: ['tool-strong'], + }), + createExactRow({ + sourceOrder: 2, + messageUuid: 'msg-turn-end', + systemSubtype: 'turn_duration', + parsedMessage: createParsedMessage({ + uuid: 'msg-turn-end', + type: 'system', + }), + }), + ], + ], + ]), + }); + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:07:00.000Z'), + task, + snapshot, + }); + + expect(evaluation).toMatchObject({ + status: 'skip', + taskId: 'task-open-strong', + skipReason: 'below_threshold', + }); + }); + + it('alerts OpenCode-owned tasks with no instrumented owner progress after threshold', () => { + const task: TeamTask = { + id: 'task-open-no-progress', + displayId: 'feed4444', + subject: 'OpenCode no progress', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T12:00:00.000Z' }], + }; + const snapshot = createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + providerByMemberName: new Map([['alice', 'opencode']]), + }); + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:07:00.000Z'), + task, + snapshot, + }); + + expect(evaluation).toMatchObject({ + status: 'alert', + taskId: 'task-open-no-progress', + branch: 'work', + signal: 'mid_turn_after_touch', + progressSignal: 'unknown', + reason: 'Potential OpenCode task stall without owner progress evidence.', + }); + expect(evaluation.epochKey).toContain('opencode_no_owner_progress'); + }); + + it('keeps non-OpenCode no-progress tasks on the existing non-instrumented skip path', () => { + const task: TeamTask = { + id: 'task-codex-no-progress', + displayId: 'feed5555', + subject: 'Codex no progress', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T12:00:00.000Z' }], + }; + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:30:00.000Z'), + task, + snapshot: createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + providerByMemberName: new Map([['alice', 'codex']]), + }), + }); + + expect(evaluation).toMatchObject({ + status: 'skip', + taskId: 'task-codex-no-progress', + skipReason: 'non_instrumented_run', + }); + }); + + it('alerts OpenCode-owned tasks with records but no owner work touch after threshold', () => { + const task: TeamTask = { + id: 'task-open-no-touch', + displayId: 'feed6666', + subject: 'OpenCode no owner touch', + owner: 'alice', + status: 'in_progress', + workIntervals: [{ startedAt: '2026-04-19T12:00:00.000Z' }], + }; + const record = createRecord({ + actor: { + memberName: 'bob', + role: 'member', + sessionId: 'session-b', + isSidechain: true, + }, + }); + + const evaluation = policy.evaluateWork({ + now: new Date('2026-04-19T12:07:00.000Z'), + task, + snapshot: createSnapshot({ + activeTasks: [task], + allTasksById: new Map([[task.id, task]]), + inProgressTasks: [task], + providerByMemberName: new Map([['alice', 'opencode']]), + recordsByTaskId: new Map([[task.id, [record]]]), + }), + }); + + expect(evaluation).toMatchObject({ + status: 'alert', + taskId: 'task-open-no-touch', + reason: 'Potential OpenCode task stall without owner work touch.', + }); + }); + it('fails closed on review branch when review has not started yet', () => { const task: TeamTask = { id: 'task-b', diff --git a/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts b/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts index 5ee13bf8..2b2b4dd0 100644 --- a/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts +++ b/test/main/services/team/stallMonitor/TeamTaskStallSnapshotSource.test.ts @@ -11,6 +11,7 @@ describe('TeamTaskStallSnapshotSource', () => { {} as never, {} as never, {} as never, + {} as never, {} as never ); @@ -42,7 +43,10 @@ describe('TeamTaskStallSnapshotSource', () => { projectDir: '/tmp/project', projectId: 'project-id', config: { - members: [{ name: 'team-lead', role: 'team lead' }], + members: [ + { name: 'team-lead', role: 'team lead', providerId: 'codex' }, + { name: 'alice', role: 'Developer', model: 'qwen/qwen3-coder' }, + ], } as never, sessionIds: ['session-a'], transcriptFiles: ['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl'], @@ -109,6 +113,15 @@ describe('TeamTaskStallSnapshotSource', () => { const exactRowReader = { parseFiles: vi.fn(async () => exactRowsByFilePath), }; + const membersMetaStore = { + getMembers: vi.fn(async () => [{ name: 'alice', providerId: 'opencode' }]), + }; + const openCodeEvidenceSource = { + readEvidence: vi.fn(async () => ({ + recordsByTaskId: new Map(), + exactRowsByFilePath: new Map(), + })), + }; const source = new TeamTaskStallSnapshotSource( locator as never, @@ -117,7 +130,9 @@ describe('TeamTaskStallSnapshotSource', () => { transcriptReader as never, batchIndexer as never, freshnessReader as never, - exactRowReader as never + exactRowReader as never, + membersMetaStore as never, + openCodeEvidenceSource as never ); const snapshot = await source.getSnapshot('demo'); @@ -130,13 +145,118 @@ describe('TeamTaskStallSnapshotSource', () => { }); expect(freshnessReader.readSignals).toHaveBeenCalledWith('/tmp/project', ['task-a', 'task-b']); expect(exactRowReader.parseFiles).toHaveBeenCalledWith(['/tmp/project/session-a.jsonl', '/tmp/project/session-b.jsonl']); + expect(openCodeEvidenceSource.readEvidence).toHaveBeenCalledWith({ + teamName: 'demo', + tasks: [activeTasks[0], activeTasks[1]], + providerByMemberName: new Map([ + ['team-lead', 'codex'], + ['alice', 'opencode'], + ]), + }); expect(snapshot?.inProgressTasks.map((task) => task.id)).toEqual(['task-a']); expect(snapshot?.reviewOpenTasks.map((task) => task.id)).toEqual(['task-b']); expect(snapshot?.leadName).toBe('team-lead'); + expect(snapshot?.providerByMemberName).toEqual( + new Map([ + ['team-lead', 'codex'], + ['alice', 'opencode'], + ]) + ); expect(snapshot?.resolvedReviewersByTaskId.get('task-b')).toEqual({ reviewer: 'alice', source: 'kanban_state', }); expect(snapshot?.recordsByTaskId).toBe(recordsByTaskId); }); + + it('merges OpenCode runtime evidence even when no Claude transcript files are available', async () => { + const task = { + id: 'task-open', + displayId: 'opencode1', + subject: 'OpenCode task', + status: 'in_progress', + owner: 'bob', + }; + const openCodeRecord = { + id: 'opencode-rec', + timestamp: '2026-04-19T12:00:00.000Z', + source: { + filePath: 'opencode-runtime:demo:bob', + sourceOrder: 1, + }, + }; + const openCodeRows = [ + { + filePath: 'opencode-runtime:demo:bob', + sourceOrder: 1, + messageUuid: 'msg-open', + timestamp: '2026-04-19T12:00:00.000Z', + parsedMessage: { + uuid: 'msg-open', + parentUuid: null, + type: 'assistant', + timestamp: new Date('2026-04-19T12:00:00.000Z'), + content: '', + isSidechain: true, + isMeta: false, + toolCalls: [], + toolResults: [], + }, + toolUseIds: [], + toolResultIds: [], + }, + ]; + const source = new TeamTaskStallSnapshotSource( + { + getContext: vi.fn(async () => ({ + projectDir: '/tmp/project', + projectId: 'project-id', + config: { + members: [ + { name: 'team-lead', role: 'team lead', providerId: 'codex' }, + { name: 'bob', role: 'Developer', providerId: 'opencode' }, + ], + }, + sessionIds: [], + transcriptFiles: [], + })), + } as never, + { + getTasks: vi.fn(async () => [task]), + getDeletedTasks: vi.fn(async () => []), + } as never, + { + getState: vi.fn(async () => ({ teamName: 'demo', tasks: {} })), + } as never, + { + readFiles: vi.fn(async () => { + throw new Error('transcript reader should not be called'); + }), + } as never, + { + buildIndex: vi.fn(() => new Map()), + } as never, + { + readSignals: vi.fn(async () => new Map()), + } as never, + { + parseFiles: vi.fn(async () => new Map()), + } as never, + { + getMembers: vi.fn(async () => []), + } as never, + { + readEvidence: vi.fn(async () => ({ + recordsByTaskId: new Map([['task-open', [openCodeRecord]]]), + exactRowsByFilePath: new Map([['opencode-runtime:demo:bob', openCodeRows]]), + })), + } as never + ); + + const snapshot = await source.getSnapshot('demo'); + + expect(snapshot?.recordsByTaskId.get('task-open')).toEqual([openCodeRecord]); + expect(snapshot?.exactRowsByFilePath.get('opencode-runtime:demo:bob')).toEqual(openCodeRows); + expect(snapshot?.transcriptFiles).toEqual([]); + }); }); diff --git a/test/main/services/team/stallMonitor/featureGates.test.ts b/test/main/services/team/stallMonitor/featureGates.test.ts index 49369b57..9ac0cf69 100644 --- a/test/main/services/team/stallMonitor/featureGates.test.ts +++ b/test/main/services/team/stallMonitor/featureGates.test.ts @@ -2,10 +2,13 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; import { getTeamTaskStallActivationGraceMs, + getOpenCodeWeakStartStallThresholdMs, getTeamTaskStallScanIntervalMs, getTeamTaskStallStartupGraceMs, + isOpenCodeTaskStallRemediationEnabled, isTeamTaskStallAlertsEnabled, isTeamTaskStallMonitorEnabled, + isTeamTaskStallScannerEnabled, } from '../../../../../src/main/services/team/stallMonitor/featureGates'; afterEach(() => { @@ -13,12 +16,15 @@ afterEach(() => { }); describe('stallMonitor feature gates', () => { - it('defaults both monitor and alerts to disabled', () => { - expect(isTeamTaskStallMonitorEnabled()).toBe(false); - expect(isTeamTaskStallAlertsEnabled()).toBe(false); + it('defaults general monitor, OpenCode remediation, scanner, and alerts to enabled', () => { + expect(isTeamTaskStallMonitorEnabled()).toBe(true); + expect(isOpenCodeTaskStallRemediationEnabled()).toBe(true); + expect(isTeamTaskStallScannerEnabled()).toBe(true); + expect(isTeamTaskStallAlertsEnabled()).toBe(true); expect(getTeamTaskStallScanIntervalMs()).toBe(60_000); expect(getTeamTaskStallStartupGraceMs()).toBe(180_000); expect(getTeamTaskStallActivationGraceMs()).toBe(120_000); + expect(getOpenCodeWeakStartStallThresholdMs()).toBe(120_000); }); it('parses truthy and falsy environment values', () => { @@ -27,11 +33,48 @@ describe('stallMonitor feature gates', () => { vi.stubEnv('CLAUDE_TEAM_TASK_STALL_SCAN_INTERVAL_MS', '1500'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_STARTUP_GRACE_MS', '2000'); vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ACTIVATION_GRACE_MS', '3000'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'yes'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS', '4000'); expect(isTeamTaskStallMonitorEnabled()).toBe(true); + expect(isOpenCodeTaskStallRemediationEnabled()).toBe(true); + expect(isTeamTaskStallScannerEnabled()).toBe(true); expect(isTeamTaskStallAlertsEnabled()).toBe(false); expect(getTeamTaskStallScanIntervalMs()).toBe(1500); expect(getTeamTaskStallStartupGraceMs()).toBe(2000); expect(getTeamTaskStallActivationGraceMs()).toBe(3000); + expect(getOpenCodeWeakStartStallThresholdMs()).toBe(4000); + }); + + it('enables the scanner when only OpenCode remediation is enabled', () => { + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'true'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'false'); + + expect(isTeamTaskStallMonitorEnabled()).toBe(false); + expect(isTeamTaskStallScannerEnabled()).toBe(true); + }); + + it('allows explicit falsy values to disable default-enabled gates', () => { + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'false'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'no'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', '0'); + + expect(isTeamTaskStallMonitorEnabled()).toBe(false); + expect(isOpenCodeTaskStallRemediationEnabled()).toBe(false); + expect(isTeamTaskStallScannerEnabled()).toBe(false); + expect(isTeamTaskStallAlertsEnabled()).toBe(false); + }); + + it('falls back to new defaults for invalid environment values', () => { + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_MONITOR_ENABLED', 'maybe'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_TASK_STALL_REMEDIATION_ENABLED', 'maybe'); + vi.stubEnv('CLAUDE_TEAM_TASK_STALL_ALERTS_ENABLED', 'maybe'); + vi.stubEnv('CLAUDE_TEAM_OPENCODE_WEAK_START_STALL_THRESHOLD_MS', 'invalid'); + + expect(isTeamTaskStallMonitorEnabled()).toBe(true); + expect(isOpenCodeTaskStallRemediationEnabled()).toBe(true); + expect(isTeamTaskStallScannerEnabled()).toBe(true); + expect(isTeamTaskStallAlertsEnabled()).toBe(true); + expect(getOpenCodeWeakStartStallThresholdMs()).toBe(120_000); }); }); diff --git a/test/renderer/components/common/TokenUsageDisplay.test.ts b/test/renderer/components/common/TokenUsageDisplay.test.ts index ce584d6d..a0e6fc52 100644 --- a/test/renderer/components/common/TokenUsageDisplay.test.ts +++ b/test/renderer/components/common/TokenUsageDisplay.test.ts @@ -89,8 +89,9 @@ describe('TokenUsageDisplay', () => { }); const popover = document.querySelector('[role="tooltip"]'); + const expectedTotalTokens = new Intl.NumberFormat().format(2250); expect(popover).toBeTruthy(); - expect(popover?.textContent).toContain('2,250'); + expect(popover?.textContent).toContain(expectedTotalTokens); expect(popover?.textContent).toContain('500 (25.0% of prompt input)'); expect(popover?.textContent).not.toContain('of context'); diff --git a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx index 68ec66de..1080e575 100644 --- a/test/renderer/components/team/ProvisioningProgressBlock.test.tsx +++ b/test/renderer/components/team/ProvisioningProgressBlock.test.tsx @@ -24,9 +24,11 @@ vi.mock('lucide-react', () => { const Icon = (props: React.SVGProps) => React.createElement('svg', props); return { AlertTriangle: Icon, + Check: Icon, CheckCircle2: Icon, ChevronDown: Icon, ChevronRight: Icon, + ClipboardList: Icon, Info: Icon, Loader2: Icon, X: Icon, @@ -38,6 +40,7 @@ import { ProvisioningProgressBlock } from '@renderer/components/team/Provisionin describe('ProvisioningProgressBlock', () => { afterEach(() => { document.body.innerHTML = ''; + vi.unstubAllGlobals(); }); it('keeps live output and CLI logs collapsed by default while launch is still running', async () => { @@ -185,4 +188,73 @@ describe('ProvisioningProgressBlock', () => { await Promise.resolve(); }); }); + + it('copies a combined diagnostics payload from the live output toolbar', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const writeText = vi.fn().mockResolvedValue(undefined); + vi.stubGlobal('navigator', { + ...navigator, + clipboard: { writeText }, + }); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(ProvisioningProgressBlock, { + title: 'Launching team', + message: 'Starting Claude CLI process', + currentStepIndex: 1, + loading: true, + defaultLiveOutputOpen: true, + startedAt: '2026-04-28T12:00:00.000Z', + pid: 321, + assistantOutput: 'Launch trace line', + cliLogsTail: '[stderr] OPENAI_API_KEY=secret-value\n[stdout] booted', + launchDiagnostics: [ + { + id: 'alice:runtime_not_found', + memberName: 'alice', + severity: 'warning', + code: 'runtime_not_found', + label: 'alice - waiting for runtime', + detail: 'codex --api-key hidden-value', + observedAt: '2026-04-28T12:00:01.000Z', + }, + ], + }) + ); + await Promise.resolve(); + }); + + const button = Array.from(host.querySelectorAll('button')).find((candidate) => + candidate.textContent?.includes('Copy diagnostics') + ); + expect(button).toBeTruthy(); + + await act(async () => { + button?.dispatchEvent(new MouseEvent('click', { bubbles: true })); + await Promise.resolve(); + }); + + expect(writeText).toHaveBeenCalledTimes(1); + const copied = String(writeText.mock.calls[0]?.[0] ?? ''); + expect(copied).toContain('# Team provisioning diagnostics'); + expect(copied).toContain('Title: Launching team'); + expect(copied).toContain('Message: Starting Claude CLI process'); + expect(copied).toContain('PID: 321'); + expect(copied).toContain('alice - waiting for runtime'); + expect(copied).toContain('Launch trace line'); + expect(copied).toContain('[stdout] booted'); + expect(copied).toContain('OPENAI_API_KEY=[redacted]'); + expect(copied).toContain('--api-key [redacted]'); + expect(copied).not.toContain('secret-value'); + expect(copied).not.toContain('hidden-value'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts index 21452851..139f5ef6 100644 --- a/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts +++ b/test/renderer/components/team/dialogs/LaunchTeamDialog.test.ts @@ -7,6 +7,7 @@ const openTeamTab = vi.fn(); const fetchCliStatus = vi.fn(); const createSchedule = vi.fn(); const updateSchedule = vi.fn(); +const teamRosterEditorSectionMock = vi.hoisted(() => ({ lastProps: null as any })); const storeState = { appConfig: { general: { multimodelEnabled: true } }, @@ -144,6 +145,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({ providerId?: string; model?: string; effort?: string; + isolation?: 'worktree'; }> ) => members.map((member, index) => ({ @@ -153,6 +155,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({ roleSelection: '', customRole: member.role ?? '', workflow: member.workflow ?? '', + isolation: member.isolation, providerId: member.providerId, model: member.model ?? '', effort: member.effort, @@ -166,7 +169,10 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({ })); vi.mock('@renderer/components/team/members/TeamRosterEditorSection', () => ({ - TeamRosterEditorSection: () => React.createElement('div', null, 'team-roster-editor'), + TeamRosterEditorSection: (props: any) => { + teamRosterEditorSectionMock.lastProps = props; + return React.createElement('div', null, 'team-roster-editor'); + }, })); vi.mock('@renderer/components/team/dialogs/SkipPermissionsCheckbox', () => ({ @@ -444,6 +450,7 @@ describe('LaunchTeamDialog', () => { vi.clearAllMocks(); storeState.cliStatus = { providers: [] }; storeState.launchParamsByTeam = {}; + teamRosterEditorSectionMock.lastProps = null; }); it('renders relaunch-specific title, warning and submit label', async () => { @@ -485,6 +492,101 @@ describe('LaunchTeamDialog', () => { }); }); + it('passes existing teammate worktree path info to the roster editor', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'launch', + open: true, + teamName: 'team-alpha', + members: [ + { + name: 'jack', + role: 'developer', + isolation: 'worktree', + cwd: '/tmp/project/.worktrees/jack', + }, + ] as any, + defaultProjectPath: '/tmp/project', + provisioningError: null, + clearProvisioningError: vi.fn(), + activeTeams: [], + onClose: vi.fn(), + onLaunch: vi.fn(async () => {}), + }) + ); + await flush(); + }); + + expect(teamRosterEditorSectionMock.lastProps?.memberInfoById).toEqual({ + 'draft-0': + 'This teammate will continue from its existing worktree: /tmp/project/.worktrees/jack', + }); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); + + it('preserves existing teammate worktree path info from saved launch request fallback', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({ + teamName: 'team-alpha', + cwd: '/tmp/project', + providerId: 'codex', + model: 'gpt-5.5', + members: [ + { + name: 'jack', + role: 'developer', + isolation: 'worktree', + cwd: '/tmp/project/.worktrees/jack', + providerId: 'opencode', + model: 'openrouter/qwen/qwen3-coder', + }, + ], + } as any); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(LaunchTeamDialog, { + mode: 'launch', + open: true, + teamName: 'team-alpha', + members: [], + defaultProjectPath: '/tmp/project', + provisioningError: null, + clearProvisioningError: vi.fn(), + activeTeams: [], + onClose: vi.fn(), + onLaunch: vi.fn(async () => {}), + }) + ); + await flush(); + }); + + expect(teamRosterEditorSectionMock.lastProps?.memberInfoById).toEqual({ + 'draft-0': + 'This teammate will continue from its existing worktree: /tmp/project/.worktrees/jack', + }); + + await act(async () => { + root.unmount(); + await flush(); + }); + }); + it('submits relaunch through onRelaunch without replacing members in-dialog', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); diff --git a/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts b/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts index e3995781..6d8c4ea9 100644 --- a/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts +++ b/test/renderer/components/team/dialogs/TaskDetailDialog.test.ts @@ -49,11 +49,15 @@ vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({ children, defaultOpen = true, onOpenChange, + badge, + headerExtra, }: { title: string; children: React.ReactNode; defaultOpen?: boolean; onOpenChange?: (isOpen: boolean) => void; + badge?: React.ReactNode; + headerExtra?: React.ReactNode; }) => { const [open, setOpen] = React.useState(defaultOpen); React.useEffect(() => { @@ -68,7 +72,13 @@ vi.mock('@renderer/components/team/CollapsibleTeamSection', () => ({ type: 'button', onClick: () => setOpen((value) => !value), }, - title + title, + badge !== undefined + ? React.createElement('span', { 'data-testid': `section-badge-${title}` }, badge) + : null, + headerExtra + ? React.createElement('span', { 'data-testid': `section-extra-${title}` }, headerExtra) + : null ), title === 'Changes' && open ? React.createElement('div', null, children) : null ); @@ -237,7 +247,7 @@ function makeSummary(taskId: string): TaskChangeSetV2 { function clickChangesSection(host: HTMLElement): void { const button = [...host.querySelectorAll('button')].find( - (candidate) => candidate.textContent === 'Changes' + (candidate) => candidate.textContent?.startsWith('Changes') === true ); if (!button) { throw new Error('Changes section button not found'); @@ -250,6 +260,7 @@ describe('TaskDetailDialog changes summary loading', () => { document.body.innerHTML = ''; vi.clearAllMocks(); vi.unstubAllGlobals(); + vi.useRealTimers(); }); it('does not drop a new task changes request while another task summary is still in flight', async () => { @@ -260,8 +271,8 @@ describe('TaskDetailDialog changes summary loading', () => { .mockImplementationOnce(() => first.promise) .mockImplementationOnce(() => second.promise); - const taskA = makeTask('task-a'); - const taskB = makeTask('task-b'); + const taskA: TeamTaskWithKanban = { ...makeTask('task-a'), changePresence: 'has_changes' }; + const taskB: TeamTaskWithKanban = { ...makeTask('task-b'), changePresence: 'has_changes' }; const host = document.createElement('div'); document.body.appendChild(host); const root = createRoot(host); @@ -335,4 +346,187 @@ describe('TaskDetailDialog changes summary loading', () => { await Promise.resolve(); }); }); + + it('keeps the changes section lazy-loadable when the task needs attention', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + hoisted.getTaskChanges.mockResolvedValueOnce({ + ...makeSummary('task-attention'), + files: [], + totalFiles: 0, + totalLinesAdded: 0, + totalLinesRemoved: 0, + confidence: 'low', + warnings: ['No file changes were recorded for this task.'], + }); + + const task: TeamTaskWithKanban = { + ...makeTask('task-attention'), + changePresence: 'needs_attention', + }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TaskDetailDialog, { + open: true, + variant: 'team', + teamName: 'team-a', + task, + taskMap: new Map(), + members: [], + onClose: vi.fn(), + onViewChanges: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect( + [...host.querySelectorAll('button')].some((button) => button.textContent === 'Changes') + ).toBe(true); + + await act(async () => { + clickChangesSection(host); + await Promise.resolve(); + }); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1); + expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith( + 'team-a', + 'task-attention', + expect.objectContaining({ summaryOnly: true }) + ); + expect(host.textContent).toContain('No file changes recorded'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('preloads the changes summary after 1.5 seconds and shows header loading state', async () => { + vi.useFakeTimers(); + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const request = deferred(); + hoisted.getTaskChanges.mockImplementationOnce(() => request.promise); + + const task: TeamTaskWithKanban = { ...makeTask('task-autoload'), changePresence: 'unknown' }; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TaskDetailDialog, { + open: true, + variant: 'team', + teamName: 'team-a', + task, + taskMap: new Map(), + members: [], + onClose: vi.fn(), + onViewChanges: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect(hoisted.getTaskChanges).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1_499); + await Promise.resolve(); + }); + expect(hoisted.getTaskChanges).not.toHaveBeenCalled(); + + await act(async () => { + vi.advanceTimersByTime(1); + await Promise.resolve(); + await Promise.resolve(); + }); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1); + expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith( + 'team-a', + 'task-autoload', + expect.objectContaining({ summaryOnly: true, forceFresh: false }) + ); + expect(host.querySelector('[data-testid="section-badge-Changes"]')).toBeNull(); + expect( + host.querySelector('[data-testid="section-extra-Changes"] .animate-spin') + ).not.toBeNull(); + + await act(async () => { + request.resolve(makeSummary('task-autoload')); + await Promise.resolve(); + }); + expect(host.querySelector('[data-testid="section-badge-Changes"]')?.textContent).toBe('1'); + + await act(async () => { + clickChangesSection(host); + await Promise.resolve(); + }); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1); + expect(host.textContent).toContain('src/task-autoload.ts'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('keeps the changes section visible for pending tasks and loads without a review handler', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + hoisted.getTaskChanges.mockResolvedValueOnce(makeSummary('task-pending')); + + const task: TeamTaskWithKanban = { + ...makeTask('task-pending'), + status: 'pending', + changePresence: 'unknown', + workIntervals: [], + } as unknown as TeamTaskWithKanban; + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(TaskDetailDialog, { + open: true, + variant: 'team', + teamName: 'team-a', + task, + taskMap: new Map(), + members: [], + onClose: vi.fn(), + }) + ); + await Promise.resolve(); + }); + + expect( + [...host.querySelectorAll('button')].some((button) => button.textContent === 'Changes') + ).toBe(true); + + await act(async () => { + clickChangesSection(host); + await Promise.resolve(); + }); + + expect(hoisted.getTaskChanges).toHaveBeenCalledTimes(1); + expect(hoisted.getTaskChanges).toHaveBeenLastCalledWith( + 'team-a', + 'task-pending', + expect.objectContaining({ summaryOnly: true }) + ); + expect(host.textContent).toContain('src/task-pending.ts'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts b/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts index db9ba6d9..db38cd37 100644 --- a/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts +++ b/test/renderer/components/team/dialogs/teammateRuntimeCompatibility.test.ts @@ -55,7 +55,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => { expect(result.memberWarningById).toEqual({}); }); - it('blocks mixed-provider teammates when tmux is unavailable', () => { + it('allows mixed-provider teammates through native process transport when tmux is unavailable', () => { const result = analyzeTeammateRuntimeCompatibility({ leadProviderId: 'anthropic', members: [{ id: 'bob', name: 'bob', providerId: 'codex' }], @@ -64,9 +64,9 @@ describe('analyzeTeammateRuntimeCompatibility', () => { tmuxStatusError: null, }); - expect(result.blocksSubmission).toBe(true); - expect(result.details.join('\n')).toContain('Mixed providers'); - expect(result.memberWarningById.bob).toContain('same provider as the Anthropic lead'); + expect(result.blocksSubmission).toBe(false); + expect(result.visible).toBe(false); + expect(result.memberWarningById).toEqual({}); }); it('allows OpenCode secondary-lane teammates without tmux under a non-OpenCode lead', () => { @@ -98,7 +98,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => { expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead'); }); - it('blocks same-provider Codex native teammates when tmux is unavailable', () => { + it('allows same-provider Codex native teammates through native process transport when tmux is unavailable', () => { const result = analyzeTeammateRuntimeCompatibility({ leadProviderId: 'codex', leadProviderBackendId: 'codex-native', @@ -108,11 +108,9 @@ describe('analyzeTeammateRuntimeCompatibility', () => { tmuxStatusError: null, }); - expect(result.blocksSubmission).toBe(true); - expect(result.title).toBe('Codex teammates need tmux before they can run'); - expect(result.message).toContain('The Codex lead can run without tmux'); - expect(result.details.join('\n')).toContain('Codex native teammates'); - expect(result.memberWarningById.jack).toContain('Codex native teammates require'); + expect(result.blocksSubmission).toBe(false); + expect(result.visible).toBe(false); + expect(result.memberWarningById).toEqual({}); }); it('allows separate-process teammate requirements when tmux is ready', () => { @@ -155,5 +153,23 @@ describe('analyzeTeammateRuntimeCompatibility', () => { expect(result.blocksSubmission).toBe(true); expect(result.details).toContain('Custom CLI args force --teammate-mode tmux.'); + expect(result.message).toContain('native process transport'); + }); + + it('blocks explicit in-process mode when a teammate requires a separate process', () => { + const result = analyzeTeammateRuntimeCompatibility({ + leadProviderId: 'anthropic', + members: [{ id: 'bob', name: 'bob', providerId: 'codex' }], + extraCliArgs: '--teammate-mode=in-process', + tmuxStatus: buildTmuxStatus(true), + tmuxStatusLoading: false, + tmuxStatusError: null, + }); + + expect(result.blocksSubmission).toBe(true); + expect(result.title).toBe('This team cannot use in-process teammates'); + expect(result.details).toContain('Custom CLI args force --teammate-mode in-process.'); + expect(result.message).toContain('native process transport'); + expect(result.memberWarningById.bob).toContain('requires a separate process'); }); }); diff --git a/test/renderer/components/team/members/CurrentTaskIndicator.test.ts b/test/renderer/components/team/members/CurrentTaskIndicator.test.ts index c538792f..b1744878 100644 --- a/test/renderer/components/team/members/CurrentTaskIndicator.test.ts +++ b/test/renderer/components/team/members/CurrentTaskIndicator.test.ts @@ -74,4 +74,30 @@ describe('CurrentTaskIndicator', () => { await Promise.resolve(); }); }); + + it('syncs the spinner animation phase across independently mounted indicators', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(CurrentTaskIndicator, { + task, + borderColor: '#3b82f6', + }) + ); + await Promise.resolve(); + }); + + const spinner = host.querySelector('svg.animate-spin') as SVGElement | null; + expect(spinner?.style.animationDelay).toMatch(/^-?\d+(\.\d+)?ms$/); + expect(spinner?.style.animationDuration).toBe('1000ms'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberCard.test.ts b/test/renderer/components/team/members/MemberCard.test.ts index e6577b9a..f91c9fb7 100644 --- a/test/renderer/components/team/members/MemberCard.test.ts +++ b/test/renderer/components/team/members/MemberCard.test.ts @@ -228,7 +228,7 @@ describe('MemberCard starting-state visuals', () => { }); }); - it('keeps runtime-pending accessibility copy honest even when launch badge is hidden by an active task', async () => { + it('keeps runtime-pending launch status visible even when the teammate has an active task', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); document.body.appendChild(host); @@ -254,6 +254,7 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); + expect(host.textContent).toContain('waiting for bootstrap'); expect(host.textContent).not.toContain('online'); expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull(); @@ -263,6 +264,50 @@ describe('MemberCard starting-state visuals', () => { }); }); + it('keeps registered-only OpenCode status visible next to active task context', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member: { + ...member, + providerId: 'opencode', + currentTaskId: currentTask.id, + }, + memberColor: 'blue', + currentTask, + isTeamAlive: true, + isTeamProvisioning: false, + spawnStatus: 'waiting', + spawnLaunchState: 'runtime_pending_bootstrap', + spawnRuntimeAlive: false, + runtimeEntry: { + memberName: 'alice', + alive: false, + restartable: false, + providerId: 'opencode', + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + updatedAt: '2026-04-27T12:17:58.714Z', + }, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('registered'); + expect(host.querySelector('[aria-label="registered"]')).not.toBeNull(); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + it('keeps the starting treatment and runtime summary visible while a runtime is still joining', async () => { vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); const host = document.createElement('div'); @@ -520,11 +565,8 @@ describe('MemberCard starting-state visuals', () => { }); expect(host.textContent).toContain('worktree'); - expect( - host.querySelector( - '[title="Worktree isolation configured. Worktree path: /tmp/project-alice-worktree"]' - ) - ).not.toBeNull(); + expect(host.textContent).toContain('Worktree isolation is enabled.'); + expect(host.textContent).toContain('Path: /tmp/project-alice-worktree'); await act(async () => { root.render( @@ -552,13 +594,8 @@ describe('MemberCard starting-state visuals', () => { }); expect(host.textContent).toContain('worktree'); - expect( - host.querySelector( - '[title="Worktree isolation is configured, but the runtime path is not available yet"]' - ) - ).not.toBeNull(); - expect(host.querySelector('[title="Worktree isolation configured. Runtime cwd: /tmp/project"]')) - .toBeNull(); + expect(host.textContent).toContain('Path is not available yet.'); + expect(host.textContent).not.toContain('Runtime cwd: /tmp/project'); await act(async () => { root.render( @@ -1006,4 +1043,40 @@ describe('MemberCard starting-state visuals', () => { await Promise.resolve(); }); }); + + it('moves worktree branch details into the worktree badge tooltip', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberCard, { + member: { + ...member, + name: 'jack', + isolation: 'worktree', + cwd: '/Users/belief/.claude/team-worktrees/sol-team-proj-abc/room/jack', + gitBranch: 'agent-teams/room/jack-abc', + }, + memberColor: 'turquoise', + isTeamAlive: true, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain('worktree'); + expect(host.textContent).toContain( + 'Path: /Users/belief/.claude/team-worktrees/sol-team-proj-abc/room/jack' + ); + expect(host.textContent).toContain('Branch: agent-teams/room/jack-abc'); + expect(host.textContent?.match(/agent-teams\/room\/jack-abc/g)).toHaveLength(1); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberDetailDialog.test.ts b/test/renderer/components/team/members/MemberDetailDialog.test.ts index 21241edf..ff4b0be1 100644 --- a/test/renderer/components/team/members/MemberDetailDialog.test.ts +++ b/test/renderer/components/team/members/MemberDetailDialog.test.ts @@ -336,4 +336,80 @@ describe('MemberDetailDialog activity count', () => { await Promise.resolve(); }); }); + + it('shows Relaunch OpenCode copy for failed OpenCode teammates without runtime evidence', async () => { + const member: ResolvedTeamMember = { + name: 'jack', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + providerId: 'opencode', + }; + const onRestartMember = vi.fn(async () => undefined); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberDetailDialog, { + open: true, + member, + teamName: 'demo-team', + members: [member], + tasks: [], + isTeamAlive: true, + spawnEntry: { + status: 'error', + launchState: 'failed_to_start', + runtimeAlive: false, + bootstrapConfirmed: false, + hardFailure: true, + hardFailureReason: 'File lock timeout: lanes.json', + agentToolAccepted: false, + livenessKind: 'registered_only', + updatedAt: '2026-04-24T12:00:00.000Z', + }, + runtimeEntry: { + memberName: 'jack', + alive: false, + restartable: true, + providerId: 'opencode', + livenessKind: 'registered_only', + runtimeDiagnostic: 'registered runtime metadata without live process', + runtimeDiagnosticSeverity: 'warning', + updatedAt: '2026-04-24T12:00:01.000Z', + }, + onClose: () => undefined, + onSendMessage: () => undefined, + onAssignTask: () => undefined, + onTaskClick: () => undefined, + onRestartMember, + }) + ); + await Promise.resolve(); + }); + + expect(host.textContent).toContain( + 'No OpenCode runtime session was recorded. Relaunch this teammate to start a fresh OpenCode session.' + ); + const relaunchButton = Array.from(host.querySelectorAll('button')).find((button) => + button.textContent?.includes('Relaunch OpenCode') + ); + expect(relaunchButton).not.toBeUndefined(); + + await act(async () => { + relaunchButton?.click(); + await Promise.resolve(); + }); + + expect(onRestartMember).toHaveBeenCalledWith('jack'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberExecutionLog.test.ts b/test/renderer/components/team/members/MemberExecutionLog.test.ts index a924be4a..6e347df4 100644 --- a/test/renderer/components/team/members/MemberExecutionLog.test.ts +++ b/test/renderer/components/team/members/MemberExecutionLog.test.ts @@ -183,4 +183,38 @@ describe('MemberExecutionLog', () => { await flushMicrotasks(); }); }); + + it('uses the member name for execution group labels when provided', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + setSingleAiGroup(); + enhanceState.value = { + displayItems: [ + { + type: 'tool', + id: 'tool-1', + toolName: 'read', + timestamp: new Date('2026-04-18T13:23:11.000Z'), + }, + ], + itemsSummary: '1 tool call', + lastOutput: null, + }; + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render(React.createElement(MemberExecutionLog, { chunks: [], memberName: 'jack' })); + await flushMicrotasks(); + }); + + expect(host.textContent).toContain('jack turn'); + expect(host.textContent).not.toContain('Agent turn'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); }); diff --git a/test/renderer/components/team/members/MemberPresenceDot.test.tsx b/test/renderer/components/team/members/MemberPresenceDot.test.tsx new file mode 100644 index 00000000..f17ae800 --- /dev/null +++ b/test/renderer/components/team/members/MemberPresenceDot.test.tsx @@ -0,0 +1,65 @@ +import React, { act } from 'react'; +import { createRoot } from 'react-dom/client'; +import { afterEach, describe, expect, it, vi } from 'vitest'; + +import { MemberPresenceDot } from '@renderer/components/team/members/MemberPresenceDot'; + +describe('MemberPresenceDot', () => { + afterEach(() => { + vi.restoreAllMocks(); + document.body.innerHTML = ''; + }); + + it('uses a shared wall-clock phase for pulse animations', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + vi.spyOn(performance, 'now').mockReturnValue(725); + + await act(async () => { + root.render( + React.createElement(MemberPresenceDot, { + className: 'size-2.5 bg-emerald-400 animate-pulse', + label: 'ready', + }) + ); + await Promise.resolve(); + }); + + const dot = host.querySelector('span') as HTMLSpanElement | null; + expect(dot?.style.animationDelay).toBe('-725ms'); + expect(dot?.style.animationDuration).toBe('2000ms'); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); + + it('does not add animation timing to static status dots', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement(MemberPresenceDot, { + className: 'size-2.5 bg-zinc-600', + label: 'offline', + }) + ); + await Promise.resolve(); + }); + + const dot = host.querySelector('span') as HTMLSpanElement | null; + expect(dot?.style.animationDelay).toBe(''); + expect(dot?.style.animationDuration).toBe(''); + + await act(async () => { + root.unmount(); + await Promise.resolve(); + }); + }); +}); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts index 3fb6efd3..e3155d10 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.integration.test.ts @@ -414,7 +414,7 @@ describe('TaskLogStreamSection integration', () => { expect(text).toContain('Task Log Stream'); expect(text).toContain('Grep'); expect(text).toContain('Edit'); - expect(text).toContain('Agent'); + expect(text).toContain('tom turn'); expect(text).toContain('3 tool calls'); expect(text).not.toContain('[]'); expect(text).not.toContain('Audit complete'); diff --git a/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx b/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx index cbcf0e10..ed594cf4 100644 --- a/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx +++ b/test/renderer/components/team/taskLogs/TaskLogStreamSection.opencode-fixture-e2e.test.tsx @@ -13,15 +13,15 @@ import type { BoardTaskLogStreamResponse, TeamTask } from '../../../../../src/sh const TEAM_NAME = 'relay-works-10'; const TASK_ID = '0b3a0624-5d66-4067-848e-5a74a1720c0d'; +const COORDINATION_TASK_ID = 'b5534868-0901-4c9e-9296-2b6e2059a08f'; const FIXTURE_PATH = path.resolve( process.cwd(), 'test/fixtures/team/opencode/relay-works-10-jack-projection-transcript.json' ); const apiState = { - getTaskLogStream: vi.fn< - (teamName: string, taskId: string) => Promise - >(), + getTaskLogStream: + vi.fn<(teamName: string, taskId: string) => Promise>(), onTeamChange: vi.fn<(callback: (event: unknown, data: unknown) => void) => () => void>(), }; @@ -54,17 +54,36 @@ const RELAY_WORKS_10_TASK: TeamTask = { ], }; +const RELAY_WORKS_10_COORDINATION_TASK: TeamTask = { + id: COORDINATION_TASK_ID, + displayId: 'b5534868', + subject: 'Split calculator implementation work', + owner: 'jack', + status: 'in_progress', + createdAt: '2026-04-24T20:28:58.000Z', + updatedAt: '2026-04-24T20:31:21.876Z', + workIntervals: [ + { + startedAt: '2026-04-24T20:28:58.000Z', + }, + ], +}; + async function loadFixtureTranscript(): Promise< NonNullable > { - const parsed = JSON.parse(await readFile(FIXTURE_PATH, 'utf8')) as OpenCodeRuntimeTranscriptResponse; + const parsed = JSON.parse( + await readFile(FIXTURE_PATH, 'utf8') + ) as OpenCodeRuntimeTranscriptResponse; if (parsed.providerId !== 'opencode' || !parsed.transcript) { throw new Error('Invalid OpenCode transcript fixture'); } return parsed.transcript; } -async function buildFixtureStream(): Promise { +async function buildFixtureStream( + task: TeamTask = RELAY_WORKS_10_TASK +): Promise { const transcript = await loadFixtureTranscript(); const source = new OpenCodeTaskLogStreamSource( { @@ -72,13 +91,13 @@ async function buildFixtureStream(): Promise { } as never, { resolve: async () => '/tmp/agent_teams_orchestrator' }, { - getTasks: vi.fn(async () => [RELAY_WORKS_10_TASK]), + getTasks: vi.fn(async () => [task]), getDeletedTasks: vi.fn(async () => []), } as never, new BoardTaskExactLogChunkBuilder(), { readTaskRecords: vi.fn(async () => []) } ); - const stream = await source.getTaskLogStream(TEAM_NAME, TASK_ID); + const stream = await source.getTaskLogStream(TEAM_NAME, task.id); if (!stream) { throw new Error('Expected OpenCode fixture stream'); } @@ -125,7 +144,7 @@ describe('TaskLogStreamSection OpenCode real fixture e2e', () => { const text = host.textContent ?? ''; expect(text).toContain('Task Log Stream'); expect(text).toContain('matched task tool markers'); - expect(text).toContain('Agent'); + expect(text).toContain('jack turn'); expect(text).toContain('Calculator behavior'); expect(text).toContain('Π—Π°Π΄Π°Ρ‡Π° #0b3a0624 Π·Π°Π²Π΅Ρ€ΡˆΠ΅Π½Π°'); expect(text).not.toContain('Keyboard handlers added'); @@ -138,4 +157,44 @@ describe('TaskLogStreamSection OpenCode real fixture e2e', () => { await flushMicrotasks(); }); }); + + it('renders real OpenCode native tool rows when the task stream includes them', async () => { + vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true); + apiState.onTeamChange.mockImplementation(() => () => undefined); + apiState.getTaskLogStream.mockResolvedValueOnce( + await buildFixtureStream(RELAY_WORKS_10_COORDINATION_TASK) + ); + + const host = document.createElement('div'); + document.body.appendChild(host); + const root = createRoot(host); + + await act(async () => { + root.render( + React.createElement( + TooltipProvider, + null, + React.createElement(TaskLogStreamSection, { + teamName: TEAM_NAME, + taskId: COORDINATION_TASK_ID, + liveEnabled: false, + }) + ) + ); + await flushMicrotasks(); + await flushMicrotasks(); + }); + + const text = host.textContent ?? ''; + expect(text).toContain('Task Log Stream'); + expect(text).toContain('read'); + expect(text).toContain('bash'); + expect(text).toContain('Π Π°Π·Π±ΠΈΠ» Ρ€Π°Π±ΠΎΡ‚Ρƒ Π½Π° ΠΌΠ΅Π»ΠΊΠΈΠ΅ Π·Π°Π΄Π°Ρ‡ΠΈ'); + expect(text).not.toContain('SendMessage'); + + await act(async () => { + root.unmount(); + await flushMicrotasks(); + }); + }); }); diff --git a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts index 5fedb8f6..5540c24f 100644 --- a/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts +++ b/test/renderer/features/agent-graph/TeamGraphAdapter.test.ts @@ -1433,6 +1433,77 @@ describe('TeamGraphAdapter particles', () => { }); }); + it('uses one offline visual state for lead and members when the team is stopped', () => { + const adapter = TeamGraphAdapter.create(); + const graph = adapter.adapt( + createBaseTeamData({ + isAlive: false, + config: { + name: 'My Team', + color: '#22d3ee', + members: [{ name: 'team-lead' }, { name: 'alice' }, { name: 'bob' }], + projectPath: '/repo', + }, + members: [ + { + name: 'team-lead', + status: 'active', + currentTaskId: null, + taskCount: 0, + lastActiveAt: null, + messageCount: 0, + agentType: 'team-lead', + }, + { + name: 'alice', + status: 'active', + color: '#0000ff', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + }, + { + name: 'bob', + status: 'idle', + color: '#ffcc00', + currentTaskId: null, + taskCount: 1, + lastActiveAt: null, + messageCount: 0, + }, + ], + }), + 'my-team', + { + alice: { + status: 'waiting', + launchState: 'starting', + updatedAt: '2026-04-08T20:00:00.000Z', + }, + }, + 'active' + ); + + expect(findNode(graph, 'lead:my-team')).toMatchObject({ + state: 'terminated', + color: undefined, + exceptionTone: 'error', + exceptionLabel: 'offline', + }); + expect(findNode(graph, 'member:my-team:alice')).toMatchObject({ + state: 'terminated', + color: undefined, + spawnStatus: undefined, + launchVisualState: undefined, + launchStatusLabel: undefined, + }); + expect(findNode(graph, 'member:my-team:bob')).toMatchObject({ + state: 'terminated', + color: undefined, + }); + }); + it('treats literal lead approval sources as lead-node pending approvals', () => { const adapter = TeamGraphAdapter.create(); const graph = adapter.adapt( diff --git a/test/renderer/features/agent-graph/drawAgents.test.ts b/test/renderer/features/agent-graph/drawAgents.test.ts index 1208a2c6..12e5fd4f 100644 --- a/test/renderer/features/agent-graph/drawAgents.test.ts +++ b/test/renderer/features/agent-graph/drawAgents.test.ts @@ -19,12 +19,17 @@ interface FillTextCall { text: string; x: number; y: number; + fillStyle: string; + globalAlpha: number; } function createMockContext() { const fillTextCalls: FillTextCall[] = []; + const strokeTextCalls: FillTextCall[] = []; const roundRectCalls: Array<{ x: number; y: number; width: number; height: number }> = []; const gradient = { addColorStop: vi.fn() }; + let fillStyle = ''; + let globalAlpha = 1; const ctx = { save: vi.fn(), @@ -51,22 +56,35 @@ function createMockContext() { createLinearGradient: vi.fn(() => gradient), measureText: vi.fn((text: string) => ({ width: text.length * 4.5 })), fillText: vi.fn((text: string, x: number, y: number) => { - fillTextCalls.push({ text, x, y }); + fillTextCalls.push({ text, x, y, fillStyle, globalAlpha }); + }), + strokeText: vi.fn((text: string, x: number, y: number) => { + strokeTextCalls.push({ text, x, y, fillStyle, globalAlpha }); }), shadowColor: '', shadowBlur: 0, shadowOffsetX: 0, shadowOffsetY: 0, - fillStyle: '', + get fillStyle() { + return fillStyle; + }, + set fillStyle(value: string) { + fillStyle = value; + }, strokeStyle: '', lineWidth: 1, font: '', textAlign: 'left' as CanvasTextAlign, textBaseline: 'alphabetic' as CanvasTextBaseline, - globalAlpha: 1, + get globalAlpha() { + return globalAlpha; + }, + set globalAlpha(value: number) { + globalAlpha = value; + }, } as unknown as CanvasRenderingContext2D; - return { ctx, fillTextCalls, roundRectCalls }; + return { ctx, fillTextCalls, strokeTextCalls, roundRectCalls }; } describe('drawAgents', () => { @@ -140,4 +158,61 @@ describe('drawAgents', () => { expect(fillTextCalls.some((call) => call.text === 'waiting...')).toBe(false); expect(fillTextCalls.some((call) => call.text === 'connecting...')).toBe(false); }); + + it('draws member labels with fixed high-contrast text and backdrops', () => { + const { ctx, fillTextCalls, strokeTextCalls, roundRectCalls } = createMockContext(); + const node: GraphNode = { + id: 'member:demo:alice', + kind: 'member', + label: 'alice', + role: 'reviewer', + state: 'idle', + color: '#0000ff', + runtimeLabel: 'Anthropic Β· Opus 4.6', + domainRef: { kind: 'member', teamName: 'demo', memberName: 'alice' }, + x: 320, + y: 240, + }; + + drawAgents(ctx, [node], 0, null, null, null, 1); + + const labelCall = fillTextCalls.find((call) => call.text === 'alice Β· reviewer'); + const runtimeCall = fillTextCalls.find((call) => call.text.includes('Anthropic')); + + expect(labelCall).toBeDefined(); + expect(runtimeCall).toBeDefined(); + expect(labelCall?.fillStyle).toBe('#e8f8ff'); + expect(runtimeCall?.fillStyle).toBe('#b9d7f2'); + expect(roundRectCalls.filter((call) => call.height === 12 || call.height === 10)).toHaveLength( + 2 + ); + expect(strokeTextCalls.some((call) => call.text === 'alice Β· reviewer')).toBe(true); + expect(strokeTextCalls.some((call) => call.text.includes('Anthropic'))).toBe(true); + }); + + it('keeps lead labels readable when the lead node is visually dimmed', () => { + const { ctx, fillTextCalls, roundRectCalls } = createMockContext(); + const node: GraphNode = { + id: 'lead:demo', + kind: 'lead', + label: 'signal-ops-12', + state: 'terminated', + color: '#0000ff', + runtimeLabel: 'GPT-5.4', + domainRef: { kind: 'lead', teamName: 'demo', memberName: 'signal-ops-12' }, + x: 320, + y: 240, + }; + + drawAgents(ctx, [node], 0, null, null, null, 1); + + const labelCall = fillTextCalls.find((call) => call.text === 'signal-ops-12'); + const runtimeCall = fillTextCalls.find((call) => call.text === 'GPT-5.4'); + + expect(labelCall).toMatchObject({ fillStyle: '#e8f8ff', globalAlpha: 0.88 }); + expect(runtimeCall).toMatchObject({ fillStyle: '#b9d7f2', globalAlpha: 0.88 }); + expect(roundRectCalls.filter((call) => call.height === 12 || call.height === 10)).toHaveLength( + 2 + ); + }); }); diff --git a/test/renderer/store/cliInstallerSlice.test.ts b/test/renderer/store/cliInstallerSlice.test.ts index 237ee822..407fea56 100644 --- a/test/renderer/store/cliInstallerSlice.test.ts +++ b/test/renderer/store/cliInstallerSlice.test.ts @@ -55,6 +55,7 @@ import { getIncompleteMultimodelProviderIds, getModelOnlyFallbackProviderIds, mergeCliStatusPreservingHydratedProviders, + reconcileMultimodelProviderLoading, } from '@renderer/store/slices/cliInstallerSlice'; import { createDefaultCliExtensionCapabilities } from '@shared/utils/providerExtensionCapabilities'; @@ -251,6 +252,44 @@ describe('cliInstallerSlice', () => { expect(getModelOnlyFallbackProviderIds(status)).toEqual([]); }); + it('clears loading for hydrated providers while keeping pending providers marked', () => { + const status = createMultimodelStatus([ + createMultimodelProvider({ + providerId: 'anthropic', + displayName: 'Anthropic', + authenticated: true, + authMethod: 'oauth_token', + statusMessage: null, + models: ['claude-sonnet-4-5'], + backend: { kind: 'anthropic', label: 'Anthropic' }, + }), + createMultimodelProvider({ + providerId: 'codex', + displayName: 'Codex', + supported: false, + authenticated: false, + authMethod: null, + verificationState: 'unknown', + statusMessage: 'Checking...', + models: [], + backend: null, + availableBackends: [], + }), + ]); + + expect( + reconcileMultimodelProviderLoading(status, { + anthropic: true, + codex: true, + opencode: true, + }) + ).toEqual({ + anthropic: false, + codex: true, + opencode: true, + }); + }); + it('still allows real OpenCode runtime errors to replace previous ready status', () => { const current = createMultimodelStatus([ createMultimodelProvider({ diff --git a/test/renderer/utils/memberHelpers.test.ts b/test/renderer/utils/memberHelpers.test.ts index d0506347..eba8969a 100644 --- a/test/renderer/utils/memberHelpers.test.ts +++ b/test/renderer/utils/memberHelpers.test.ts @@ -52,6 +52,18 @@ describe('memberHelpers spawn-aware presence', () => { undefined ) ).toContain('bg-emerald-400'); + expect( + getSpawnAwareDotClass( + member, + 'online', + 'runtime_pending_bootstrap', + true, + false, + true, + false, + undefined + ) + ).toContain('animate-pulse'); }); it('keeps accepted-but-not-yet-online teammates in starting state', () => { diff --git a/test/renderer/utils/renderHelpers.test.ts b/test/renderer/utils/renderHelpers.test.ts index f1aaa35d..89be2fc6 100644 --- a/test/renderer/utils/renderHelpers.test.ts +++ b/test/renderer/utils/renderHelpers.test.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; -import { extractOutputText } from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers'; +import { + extractOutputText, + formatToolOutputForDisplay, +} from '../../../src/renderer/components/chat/items/linkedTool/renderHelpers'; describe('renderHelpers', () => { describe('extractOutputText', () => { @@ -56,4 +59,47 @@ describe('renderHelpers', () => { expect(result).toContain('"type": "image"'); }); }); + + describe('formatToolOutputForDisplay', () => { + it('unwraps Agent Teams task comment responses for display', () => { + const raw = JSON.stringify({ + agent_teams_task_add_comment_response: { + comment: { + attachments: [], + author: 'bob', + createdAt: '2026-04-27T20:05:44.248Z', + id: '40203f1f-44e2-45e0-b6a8-2b812fb7ac12', + text: 'Π‘ΠΎΠ·Π΄Π°Π½Π° ΠΏΠ°ΠΏΠΊΠ° `944` ΠΈ Ρ„Π°ΠΉΠ» `calculator.js`.', + }, + taskId: '03561cb3-55d3-46c1-9f06-b928750936a9', + teamName: 'forge-labs-9', + }, + }); + + const result = formatToolOutputForDisplay('agent-teams_task_add_comment', raw); + + expect(result).toContain('Task comment added'); + expect(result).toContain('Team: forge-labs-9'); + expect(result).toContain('Comment ID: 40203f1f-44e2-45e0-b6a8-2b812fb7ac12'); + expect(result).toContain('Π‘ΠΎΠ·Π΄Π°Π½Π° ΠΏΠ°ΠΏΠΊΠ° `944`'); + expect(result).not.toContain('agent_teams_task_add_comment_response'); + }); + + it('does not rewrite non Agent Teams JSON output', () => { + const raw = JSON.stringify({ agent_teams_task_add_comment_response: { ok: true } }); + + expect(formatToolOutputForDisplay('bash', raw)).toBe(raw); + }); + + it('keeps Agent Teams error payloads raw for debugging', () => { + const raw = JSON.stringify({ + agent_teams_task_add_comment_response: { + error: 'Task not found', + taskId: 'missing', + }, + }); + + expect(formatToolOutputForDisplay('agent-teams_task_add_comment', raw)).toBe(raw); + }); + }); }); diff --git a/test/renderer/utils/streamJsonParser.test.ts b/test/renderer/utils/streamJsonParser.test.ts new file mode 100644 index 00000000..6a5140a0 --- /dev/null +++ b/test/renderer/utils/streamJsonParser.test.ts @@ -0,0 +1,90 @@ +import { describe, expect, it } from 'vitest'; + +import { parseStreamJsonToGroups } from '@renderer/utils/streamJsonParser'; + +describe('parseStreamJsonToGroups', () => { + it('renders Codex native JSONL lifecycle and assistant text instead of showing an empty viewer', () => { + const groups = parseStreamJsonToGroups( + [ + '[stdout]', + '{"type":"thread.started","thread_id":"thread-1"}', + '{"type":"turn.started"}', + '{"type":"item.completed","item":{"id":"item_0","type":"agent_message","text":"Lead response ready."}}', + '{"type":"turn.completed","usage":{"input_tokens":100,"cached_input_tokens":25,"output_tokens":7}}', + ].join('\n') + ); + + expect(groups).toHaveLength(1); + expect(groups[0]?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ type: 'output', content: 'Codex native thread started: thread-1.' }), + expect.objectContaining({ type: 'output', content: 'Codex turn started.' }), + expect.objectContaining({ type: 'output', content: 'Lead response ready.' }), + expect.objectContaining({ + type: 'output', + content: 'Codex turn completed (100 input, 25 cached, 7 output tokens).', + }), + ]) + ); + }); + + it('deduplicates Codex native MCP tool started/completed events by item id', () => { + const groups = parseStreamJsonToGroups( + [ + '{"type":"item.started","item":{"id":"item_1","type":"mcp_tool_call","server":"agent-teams","tool":"message_send","arguments":{"teamName":"signal-ops-11"},"status":"in_progress"}}', + '{"type":"item.completed","item":{"id":"item_1","type":"mcp_tool_call","server":"agent-teams","tool":"message_send","arguments":{"teamName":"signal-ops-11"},"result":{"content":[{"type":"text","text":"sent"}]},"status":"completed"}}', + ].join('\n') + ); + + const tools = groups.flatMap((group) => group.items).filter((item) => item.type === 'tool'); + + expect(tools).toHaveLength(1); + expect(tools[0]).toMatchObject({ + type: 'tool', + tool: { + id: 'item_1', + name: 'agent-teams_message_send', + isOrphaned: false, + result: { + content: 'sent', + isError: false, + }, + }, + }); + }); + + it('renders projected Codex native system status rows from persisted logs', () => { + const groups = parseStreamJsonToGroups( + [ + '{"type":"system","subtype":"codex_native_thread_status","content":"Codex native thread started (thread-1).","codexNativeThreadStatus":"running","codexNativeThreadId":"thread-1"}', + '{"type":"system","subtype":"codex_native_execution_summary","content":"Codex native execution summary: ephemeral live-only."}', + ].join('\n') + ); + + expect(groups).toHaveLength(1); + expect(groups[0]?.items).toEqual( + expect.arrayContaining([ + expect.objectContaining({ + type: 'output', + content: 'Codex native thread started (thread-1).', + }), + expect.objectContaining({ + type: 'output', + content: 'Codex native execution summary: ephemeral live-only.', + }), + ]) + ); + }); + + it('keeps legacy assistant stream-json behavior', () => { + const groups = parseStreamJsonToGroups( + '{"type":"assistant","message":{"id":"msg_1","content":[{"type":"text","text":"Legacy assistant output."}]}}' + ); + + expect(groups).toHaveLength(1); + expect(groups[0]?.id).toBe('stream-group-msg_1'); + expect(groups[0]?.items).toEqual([ + expect.objectContaining({ type: 'output', content: 'Legacy assistant output.' }), + ]); + }); +}); diff --git a/test/renderer/utils/teamListStatus.test.ts b/test/renderer/utils/teamListStatus.test.ts new file mode 100644 index 00000000..4fc345ac --- /dev/null +++ b/test/renderer/utils/teamListStatus.test.ts @@ -0,0 +1,123 @@ +import { describe, expect, it } from 'vitest'; + +import { isTeamListStatusRunning, resolveTeamStatus } from '@renderer/utils/teamListStatus'; + +import type { TeamProvisioningProgress, TeamSummary } from '@shared/types'; + +function team(patch: Partial = {}): TeamSummary { + return { + teamName: 'atlas-hq-10', + displayName: 'atlas-hq-10', + description: '', + color: 'blue', + memberCount: 4, + members: [], + taskCount: 0, + lastActivity: null, + ...patch, + } as TeamSummary; +} + +function progress( + state: TeamProvisioningProgress['state'], + updatedAt: string +): TeamProvisioningProgress { + return { + runId: 'run-1', + teamName: 'atlas-hq-10', + state, + message: state, + startedAt: updatedAt, + updatedAt, + }; +} + +describe('team list status', () => { + const nowMs = Date.parse('2026-04-28T20:00:00.000Z'); + + it('treats active provisioning as launching even if the previous lead state was offline', () => { + expect( + resolveTeamStatus( + team(), + 'atlas-hq-10', + [], + progress('assembling', '2026-04-28T19:59:59.000Z'), + { 'atlas-hq-10': 'offline' }, + nowMs + ) + ).toBe('provisioning'); + }); + + it('keeps a recent ready launch running until aliveList catches up', () => { + expect( + resolveTeamStatus( + team(), + 'atlas-hq-10', + [], + progress('ready', '2026-04-28T19:59:45.000Z'), + {}, + nowMs + ) + ).toBe('idle'); + }); + + it('does not let optimistic ready override an explicit offline lead event', () => { + expect( + resolveTeamStatus( + team(), + 'atlas-hq-10', + [], + progress('ready', '2026-04-28T19:59:45.000Z'), + { 'atlas-hq-10': 'offline' }, + nowMs + ) + ).toBe('offline'); + }); + + it('does not let stale aliveList data override an explicit offline lead event', () => { + expect( + resolveTeamStatus( + team(), + 'atlas-hq-10', + ['atlas-hq-10'], + null, + { 'atlas-hq-10': 'offline' }, + nowMs + ) + ).toBe('offline'); + }); + + it('expires optimistic ready state if aliveList still does not report the team alive', () => { + expect( + resolveTeamStatus( + team(), + 'atlas-hq-10', + [], + progress('ready', '2026-04-28T19:58:00.000Z'), + {}, + nowMs + ) + ).toBe('offline'); + }); + + it('does not mask partial launch failures as optimistic running', () => { + expect( + resolveTeamStatus( + team({ partialLaunchFailure: true, teamLaunchState: 'partial_failure' }), + 'atlas-hq-10', + [], + progress('ready', '2026-04-28T19:59:45.000Z'), + {}, + nowMs + ) + ).toBe('partial_failure'); + }); + + it('classifies running filter state consistently', () => { + expect(isTeamListStatusRunning('idle')).toBe(true); + expect(isTeamListStatusRunning('provisioning')).toBe(true); + expect(isTeamListStatusRunning('offline')).toBe(false); + expect(isTeamListStatusRunning('partial_failure')).toBe(false); + expect(isTeamListStatusRunning('partial_pending')).toBe(false); + }); +}); diff --git a/test/renderer/utils/toolSummaryHelpers.test.ts b/test/renderer/utils/toolSummaryHelpers.test.ts new file mode 100644 index 00000000..dcddf378 --- /dev/null +++ b/test/renderer/utils/toolSummaryHelpers.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from 'vitest'; + +import { getToolSummary } from '../../../src/renderer/utils/toolRendering/toolSummaryHelpers'; + +describe('renderer toolSummaryHelpers', () => { + it('summarizes OpenCode lowercase write calls with camelCase filePath', () => { + expect( + getToolSummary('write', { + filePath: '/repo/944/index.html', + content: '\n', + }) + ).toBe('index.html - 2 lines'); + }); + + it('shows an explicit unavailable summary for invalid empty write calls', () => { + expect(getToolSummary('write', {})).toBe('Write input unavailable'); + }); + + it('summarizes OpenCode lowercase read and bash calls', () => { + expect(getToolSummary('read', { filePath: '/repo/944/style.css' })).toBe('style.css'); + expect(getToolSummary('bash', { command: 'mkdir -p 944' })).toBe('mkdir -p 944'); + }); +}); diff --git a/test/shared/utils/contentSanitizer.test.ts b/test/shared/utils/contentSanitizer.test.ts index d6c71087..e793e7de 100644 --- a/test/shared/utils/contentSanitizer.test.ts +++ b/test/shared/utils/contentSanitizer.test.ts @@ -45,3 +45,25 @@ describe('contentSanitizer task notifications', () => { expect(parseTaskNotifications('normal user content')).toEqual([]); }); }); + +describe('contentSanitizer OpenCode delivery envelopes', () => { + it('hides OpenCode delivery instructions and retry metadata while keeping inbound content', () => { + const content = [ + '', + 'To make your reply visible in the app Messages UI, call MCP tool agent-teams_message_send.', + '', + '', + '', + '', + 'This is retry attempt 3/3 for inbound app messageId "message-1".', + '', + '', + 'New task assigned to you: #task-a Investigate failing command', + '', + ].join('\n'); + + expect(sanitizeDisplayContent(content)).toBe( + 'New task assigned to you: #task-a Investigate failing command' + ); + }); +});