merge(dev): integrate dev into main
|
|
@ -17,7 +17,7 @@
|
|||
</p>
|
||||
|
||||
<p align="center">
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/release/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/releases/latest"><img src="https://img.shields.io/github/v/tag/777genius/claude_agent_teams_ui?style=flat-square&label=version&color=blue" alt="Latest Release" /></a>
|
||||
<a href="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml"><img src="https://github.com/777genius/claude_agent_teams_ui/actions/workflows/ci.yml/badge.svg" alt="CI Status" /></a>
|
||||
<a href="https://discord.gg/qtqSZSyuEc"><img src="https://img.shields.io/badge/Discord-Join%20us-5865F2?style=flat-square&logo=discord&logoColor=white" alt="Discord" /></a>
|
||||
</p>
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ function createControllerContext(options = {}) {
|
|||
teamName,
|
||||
claudeDir: paths.claudeDir,
|
||||
paths,
|
||||
allowUserMessageSender: options.allowUserMessageSender !== false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
345
docs/research/messenger-connectors-uncertainty-pass-27.md
Normal file
|
|
@ -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<void>;
|
||||
afterSoftDeleteTeam(input: { teamName: string; deletedAt: string }): Promise<void>;
|
||||
beforeRestoreTeam(input: { teamName: string }): Promise<void>;
|
||||
afterRestoreTeam(input: { teamName: string }): Promise<void>;
|
||||
beforePermanentDeleteTeam(input: { teamName: string; deleteLocalConnectorPlaintext: boolean }): Promise<void>;
|
||||
afterPermanentDeleteTeam(input: { teamName: string }): Promise<void>;
|
||||
afterTeamConfigChanged(input: { teamName: string; previousDisplayName: string; nextDisplayName: string }): Promise<void>;
|
||||
};
|
||||
```
|
||||
|
||||
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.
|
||||
528
docs/research/messenger-connectors-uncertainty-pass-28.md
Normal file
|
|
@ -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<void>;
|
||||
|
||||
observeInternalMessage(message: InboxMessage): Promise<void>;
|
||||
complete(input: { internalTurnId: string; reason: string }): Promise<void>;
|
||||
};
|
||||
```
|
||||
|
||||
Reply proof order:
|
||||
|
||||
```text
|
||||
1. SendMessage(to="user", relayOfMessageId=<inboundInternalMessageId>)
|
||||
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:<memberRouteGeneration>
|
||||
```
|
||||
|
||||
## 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: <text>"
|
||||
|
||||
Teammate:
|
||||
"<member display name>: <text>"
|
||||
|
||||
Unknown member:
|
||||
"Team: <text>"
|
||||
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.
|
||||
|
|
@ -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'],
|
||||
|
|
|
|||
1
mcp-server/src/agent-teams-controller.d.ts
vendored
|
|
@ -2,6 +2,7 @@ declare module 'agent-teams-controller' {
|
|||
export interface ControllerContextOptions {
|
||||
teamName: string;
|
||||
claudeDir?: string;
|
||||
allowUserMessageSender?: boolean;
|
||||
}
|
||||
|
||||
export interface ControllerTaskApi {
|
||||
|
|
|
|||
|
|
@ -23,5 +23,6 @@ export function getController(teamName: string, claudeDir?: string) {
|
|||
return createController({
|
||||
teamName,
|
||||
...(resolvedClaudeDir ? { claudeDir: resolvedClaudeDir } : {}),
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,12 +14,12 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
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(),
|
||||
|
|
|
|||
|
|
@ -436,12 +436,13 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -1353,6 +1353,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
{ name: 'bob', role: 'reviewer' },
|
||||
],
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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) ─────────────────────────────────────────
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<void> = 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(),
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
});
|
||||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<string | null> {
|
||||
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<CodexSessionFileEntry[]> {
|
||||
async function walk(directory: string, depth: number): Promise<CodexSessionFileEntry[]> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await fs.readdir(directory, { withFileTypes: true, encoding: 'utf8' });
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
|
||||
const files = await Promise.all(
|
||||
entries.map(async (entry): Promise<CodexSessionFileEntry[]> => {
|
||||
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<RecentProjectsSourceResult> {
|
||||
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<CodexSessionProjectSnapshot[]> {
|
||||
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<string, CodexSessionProjectSnapshot>();
|
||||
|
||||
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<RecentProjectCandidate | null> {
|
||||
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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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<DashboardRecentProjectsPayload>();
|
||||
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,
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -4,11 +4,11 @@ import type {
|
|||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadSetupFormInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementSetupFormResponse,
|
||||
|
|
|
|||
|
|
@ -5,11 +5,11 @@ import type {
|
|||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadSetupFormInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadSetupFormInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementSetupFormResponse,
|
||||
|
|
|
|||
|
|
@ -18,11 +18,11 @@ import type {
|
|||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadSetupFormInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadSetupFormInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementSetupFormResponse,
|
||||
|
|
|
|||
|
|
@ -8,11 +8,11 @@ import type {
|
|||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadSetupFormInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadSetupFormInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementSetupFormResponse,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,8 @@ import type {
|
|||
RuntimeProviderManagementErrorDto,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadSetupFormInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadSetupFormInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
|
|
|
|||
|
|
@ -17,11 +17,11 @@ import type {
|
|||
RuntimeProviderManagementDirectoryResponse,
|
||||
RuntimeProviderManagementForgetInput,
|
||||
RuntimeProviderManagementLoadDirectoryInput,
|
||||
RuntimeProviderManagementLoadSetupFormInput,
|
||||
RuntimeProviderManagementLoadModelsInput,
|
||||
RuntimeProviderManagementLoadSetupFormInput,
|
||||
RuntimeProviderManagementLoadViewInput,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementModelsResponse,
|
||||
RuntimeProviderManagementModelTestResponse,
|
||||
RuntimeProviderManagementProviderResponse,
|
||||
RuntimeProviderManagementSetDefaultModelInput,
|
||||
RuntimeProviderManagementSetupFormResponse,
|
||||
|
|
|
|||
|
|
@ -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' },
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)]
|
||||
|
|
|
|||
|
|
@ -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'
|
||||
);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -40,6 +40,13 @@ export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise<
|
|||
return runtimeCommandExecutor.listRuntimeProcesses();
|
||||
}
|
||||
|
||||
export async function sendKeysToTmuxPaneForCurrentPlatform(
|
||||
paneId: string,
|
||||
command: string
|
||||
): Promise<void> {
|
||||
await runtimeCommandExecutor.sendKeysToPane(paneId, command);
|
||||
}
|
||||
|
||||
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
|
||||
runtimeCommandExecutor.killPaneSync(paneId);
|
||||
invalidateTmuxRuntimeStatusCache();
|
||||
|
|
|
|||
|
|
@ -12,6 +12,7 @@ export {
|
|||
listRuntimeProcessesForCurrentTmuxPlatform,
|
||||
listTmuxPanePidsForCurrentPlatform,
|
||||
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
||||
sendKeysToTmuxPaneForCurrentPlatform,
|
||||
} from './composition/runtimeSupport';
|
||||
export type {
|
||||
RuntimeProcessTableRow,
|
||||
|
|
|
|||
|
|
@ -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<ExecResult> {
|
||||
async execTmux(args: string[], timeout = 5_000, socketName?: string): Promise<ExecResult> {
|
||||
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<void> {
|
||||
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<Map<string, TmuxPaneRuntimeInfo>> {
|
||||
|
|
@ -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<string, TmuxPaneRuntimeInfo>();
|
||||
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<void> {
|
||||
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<string[]> {
|
||||
const dirs = this.#getNativeTmuxSocketDirs();
|
||||
const names = new Set<string>();
|
||||
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<string>();
|
||||
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<string> {
|
||||
const platform =
|
||||
process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -1079,7 +1079,7 @@ async function initializeServices(): Promise<void> {
|
|||
new TeamTaskStallSnapshotSource(),
|
||||
new TeamTaskStallPolicy(),
|
||||
new TeamTaskStallJournal(),
|
||||
new TeamTaskStallNotifier(teamDataService)
|
||||
new TeamTaskStallNotifier(teamDataService, teamProvisioningService)
|
||||
);
|
||||
let teammateToolTracker: TeammateToolTracker | null = null;
|
||||
branchStatusService = new BranchStatusService((event) => {
|
||||
|
|
|
|||
|
|
@ -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<TeamLaunchRequest>;
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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<void>>();
|
||||
|
||||
vi.mock('node:fs/promises', () => ({
|
||||
|
|
|
|||
|
|
@ -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<CliProviderId, CliProviderStatus>
|
||||
): CliProviderStatus[] {
|
||||
return ORDERED_PROVIDER_IDS.map(
|
||||
(providerId) => providers.get(providerId) ?? createPendingProviderStatus(providerId)
|
||||
);
|
||||
}
|
||||
|
||||
private async getProviderStatusFromRuntimeStatusCommand(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId,
|
||||
env: NodeJS.ProcessEnv,
|
||||
connectionIssues: Partial<Record<CliProviderId, string>>
|
||||
): Promise<CliProviderStatus> {
|
||||
const { stdout } = await execCli(
|
||||
binaryPath,
|
||||
['runtime', 'status', '--json', '--provider', providerId],
|
||||
{
|
||||
timeout: PROVIDER_STATUS_TIMEOUT_MS,
|
||||
env,
|
||||
}
|
||||
);
|
||||
const parsed = extractJsonObject<UnifiedRuntimeStatusResponse>(stdout);
|
||||
return providerConnectionService.enrichProviderStatus(
|
||||
this.applyConnectionIssue(
|
||||
this.mapRuntimeProviderStatus(providerId, parsed.providers?.[providerId]),
|
||||
connectionIssues
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
private async getProviderStatusFromScopedRuntimeStatus(
|
||||
binaryPath: string,
|
||||
providerId: CliProviderId
|
||||
): Promise<CliProviderStatus> {
|
||||
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<CliProviderStatus[] | null> {
|
||||
const providers = new Map<CliProviderId, CliProviderStatus>(
|
||||
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<OpenCodeRuntimeVerifyResponse['snapshot'] | null> {
|
||||
|
|
@ -761,24 +872,9 @@ export class ClaudeMultimodelBridgeService {
|
|||
providerId: CliProviderId
|
||||
): Promise<CliProviderStatus> {
|
||||
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<UnifiedRuntimeStatusResponse>(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<CliProviderStatus[]> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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<Record<CliProviderId, string>> = {
|
|||
};
|
||||
|
||||
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<CodexAccountFeatureFacade, 'getSnapshot'> | 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,
|
||||
|
|
|
|||
83
src/main/services/runtime/cliSettingsArgs.ts
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
type JsonObject = Record<string, unknown>;
|
||||
|
||||
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;
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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<string>(['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,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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':
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<TeamMemberWorktreeResolution> {
|
||||
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 };
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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<FileLockOptions>): 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<T>(filePath: string, fn: () => Promise<T>): Promise<T> {
|
||||
export async function withFileLock<T>(
|
||||
filePath: string,
|
||||
fn: () => Promise<T>,
|
||||
options: FileLockOptions = {}
|
||||
): Promise<T> {
|
||||
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 {
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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<void> {
|
||||
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: {
|
||||
|
|
|
|||
|
|
@ -274,6 +274,57 @@ export class RuntimeStoreManifestStore {
|
|||
return readStoreDataOrThrow(this.store);
|
||||
}
|
||||
|
||||
async setActiveRun(input: {
|
||||
runId: string | null;
|
||||
capabilitySnapshotId?: string | null;
|
||||
behaviorFingerprint?: string | null;
|
||||
}): Promise<RuntimeStoreManifest> {
|
||||
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<void> {
|
||||
await this.store.updateLocked((manifest) => ({
|
||||
...manifest,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<DesktopTeammateModeDecision, 'forceProcessTeammates'>
|
||||
): void {
|
||||
if (decision.forceProcessTeammates) {
|
||||
env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES = '1';
|
||||
return;
|
||||
}
|
||||
|
||||
delete env.CLAUDE_TEAM_FORCE_PROCESS_TEAMMATES;
|
||||
}
|
||||
|
||||
export function buildDesktopTeammateModeCliArgs(
|
||||
decision: Pick<DesktopTeammateModeDecision, 'injectedTeammateMode'>
|
||||
): string[] {
|
||||
return decision.injectedTeammateMode ? ['--teammate-mode', decision.injectedTeammateMode] : [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,11 @@ interface TeamLogSourceTrackingHandle {
|
|||
): Promise<{ projectFingerprint: string | null; logSourceGeneration: string | null }>;
|
||||
}
|
||||
|
||||
function unrefBackgroundTimer(timer: ReturnType<typeof setInterval>): void {
|
||||
const maybeTimer = timer as { unref?: () => void };
|
||||
maybeTimer.unref?.();
|
||||
}
|
||||
|
||||
export class ActiveTeamRegistry {
|
||||
private readonly activeTeams = new Set<string>();
|
||||
private reconcileTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
|
@ -61,6 +66,7 @@ export class ActiveTeamRegistry {
|
|||
this.reconcileTimer = setInterval(() => {
|
||||
void this.reconcile();
|
||||
}, this.reconcileIntervalMs);
|
||||
unrefBackgroundTimer(this.reconcileTimer);
|
||||
}
|
||||
|
||||
async stop(): Promise<void> {
|
||||
|
|
|
|||
|
|
@ -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<string>([
|
||||
'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<string>([
|
||||
'taskid',
|
||||
'task_id',
|
||||
'targetid',
|
||||
'targettaskid',
|
||||
'target_task_id',
|
||||
'canonicalid',
|
||||
'canonical_id',
|
||||
'displayid',
|
||||
'display_id',
|
||||
]);
|
||||
|
||||
const TEAM_REFERENCE_KEYS = new Set<string>(['team', 'teamid', 'team_id', 'teamname', 'team_name']);
|
||||
|
||||
interface BinaryResolverLike {
|
||||
resolve(): Promise<string | null>;
|
||||
}
|
||||
|
||||
interface RuntimeBridgeLike {
|
||||
getOpenCodeTranscript(
|
||||
binaryPath: string,
|
||||
params: {
|
||||
teamId: string;
|
||||
memberName: string;
|
||||
limit?: number;
|
||||
}
|
||||
): Promise<Awaited<ReturnType<ClaudeMultimodelBridgeService['getOpenCodeTranscript']>>>;
|
||||
}
|
||||
|
||||
export interface OpenCodeTaskStallEvidence {
|
||||
recordsByTaskId: Map<string, BoardTaskActivityRecord[]>;
|
||||
exactRowsByFilePath: Map<string, TeamTaskStallExactRow[]>;
|
||||
}
|
||||
|
||||
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<string> {
|
||||
return new Set(
|
||||
[task.id, task.displayId]
|
||||
.map(normalizeTaskRef)
|
||||
.filter((value): value is string => value !== null)
|
||||
);
|
||||
}
|
||||
|
||||
function collectNormalizedRefs(value: unknown, depth = 0): Set<string> {
|
||||
const refs = new Set<string>();
|
||||
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<string, unknown>)) {
|
||||
for (const ref of collectNormalizedRefs(nestedValue, depth + 1)) {
|
||||
refs.add(ref);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return refs;
|
||||
}
|
||||
|
||||
function collectExplicitRefsForKeys(value: unknown, keys: Set<string>, depth = 0): Set<string> {
|
||||
const refs = new Set<string>();
|
||||
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<string, unknown>)) {
|
||||
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<string>, right: Set<string>): boolean {
|
||||
for (const value of left) {
|
||||
if (right.has(value)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
function valueReferencesTask(value: unknown, taskRefs: Set<string>, 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<string, unknown>).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<string>
|
||||
): 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<BoardTaskActivityRecord['action']>['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, unknown>): 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<string, TeamProviderId>
|
||||
): Map<string, TeamTask[]> {
|
||||
const grouped = new Map<string, TeamTask[]>();
|
||||
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<string, TeamProviderId>;
|
||||
}): Promise<OpenCodeTaskStallEvidence> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -24,6 +24,7 @@ export class TeamTaskStallJournal {
|
|||
teamName: string;
|
||||
evaluations: TaskStallEvaluation[];
|
||||
activeTaskIds: string[];
|
||||
scopeTaskIds?: string[];
|
||||
now: string;
|
||||
}): Promise<TaskStallEvaluation[]> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<typeof setTimeout>): void {
|
||||
const maybeTimer = timer as { unref?: () => void };
|
||||
maybeTimer.unref?.();
|
||||
}
|
||||
|
||||
export class TeamTaskStallMonitor {
|
||||
private scanTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
private nudgeTimer: ReturnType<typeof setTimeout> | 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<void> {
|
||||
|
|
@ -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<string>();
|
||||
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<ReturnType<TeamTaskStallSnapshotSource['getSnapshot']>>,
|
||||
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<Awaited<ReturnType<TeamTaskStallSnapshotSource['getSnapshot']>>>
|
||||
): 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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<OpenCodeTaskStallRelayService['relayOpenCodeMemberInboxMessages']>
|
||||
>;
|
||||
type OpenCodeTaskStallDelivery = NonNullable<OpenCodeTaskStallRelayResult['lastDelivery']>;
|
||||
|
||||
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<TeamDataService, 'sendSystemNotificationToLead'>
|
||||
private readonly teamDataService: Pick<TeamDataService, 'sendSystemNotificationToLead'>,
|
||||
private readonly teamProvisioningService?: OpenCodeTaskStallRelayService,
|
||||
private readonly inboxReader: Pick<TeamInboxReader, 'getMessagesFor'> = new TeamInboxReader(),
|
||||
private readonly inboxWriter: Pick<TeamInboxWriter, 'sendMessage'> = new TeamInboxWriter()
|
||||
) {}
|
||||
|
||||
async notifyLead(teamName: string, alerts: TaskStallAlert[]): Promise<void> {
|
||||
|
|
@ -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<boolean> {
|
||||
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<TaskStallAlert[]> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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('_', ' ')}.`,
|
||||
});
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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<string, TeamProviderId> {
|
||||
const providerByMemberName = new Map<string, TeamProviderId>();
|
||||
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<TeamTaskStallSnapshot | null> {
|
||||
|
|
@ -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<string, BoardTaskActivityRecord[]>();
|
||||
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<string, BoardTaskActivityRecord[]>,
|
||||
extra: Map<string, BoardTaskActivityRecord[]>
|
||||
): Map<string, BoardTaskActivityRecord[]> {
|
||||
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<string, TeamTaskStallExactRow[]>,
|
||||
extra: Map<string, TeamTaskStallExactRow[]>
|
||||
): Map<string, TeamTaskStallExactRow[]> {
|
||||
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<string, BoardTaskActivityRecord[]>
|
||||
|
|
|
|||
|
|
@ -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<string, BoardTaskActivityRecord[]>;
|
||||
freshnessByTaskId: Map<string, TaskLogFreshnessSignal>;
|
||||
exactRowsByFilePath: Map<string, TeamTaskStallExactRow[]>;
|
||||
providerByMemberName: Map<string, TeamProviderId>;
|
||||
}
|
||||
|
||||
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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
108
src/main/services/team/taskLogs/TranscriptSessionActorContext.ts
Normal file
|
|
@ -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<T extends Record<string, unknown>>(
|
||||
record: T,
|
||||
updates: Record<string, unknown>
|
||||
): T {
|
||||
return {
|
||||
...record,
|
||||
...updates,
|
||||
};
|
||||
}
|
||||
|
||||
export class TranscriptSessionActorContextTracker {
|
||||
private readonly contextsBySessionId = new Map<string, SessionActorContextState>();
|
||||
|
||||
remember(record: Record<string, unknown>): 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<T extends Record<string, unknown>>(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<string, unknown> = {};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
@ -183,7 +183,7 @@ function extractBoardToolOutputText(
|
|||
return null;
|
||||
}
|
||||
|
||||
const payload = parsedPayload as Record<string, unknown>;
|
||||
const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record<string, unknown>);
|
||||
if (toolName === 'task_add_comment' || toolName === 'task_get_comment') {
|
||||
const comment = payload.comment as Record<string, unknown> | undefined;
|
||||
if (typeof comment?.text === 'string' && comment.text.trim().length > 0) {
|
||||
|
|
@ -194,6 +194,22 @@ function extractBoardToolOutputText(
|
|||
return null;
|
||||
}
|
||||
|
||||
function unwrapAgentTeamsResponsePayload(
|
||||
payload: Record<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown>)
|
||||
: 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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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<T, R>(
|
|||
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<string, unknown> | null {
|
|||
return value && typeof value === 'object' ? (value as Record<string, unknown>) : null;
|
||||
}
|
||||
|
||||
function lineMayContainTaskActivityOrActorContext(line: string): boolean {
|
||||
return (
|
||||
line.includes('"boardTaskLinks"') || line.includes('"agentName"') || line.includes('"agentId"')
|
||||
);
|
||||
}
|
||||
|
||||
export class BoardTaskActivityTranscriptReader {
|
||||
private readonly cache = new BoardTaskActivityParseCache<RawTaskActivityMessage[]>();
|
||||
|
||||
|
|
@ -112,6 +119,7 @@ export class BoardTaskActivityTranscriptReader {
|
|||
|
||||
private async parseFile(filePath: string): Promise<RawTaskActivityMessage[]> {
|
||||
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,
|
||||
|
|
|
|||
|
|
@ -170,6 +170,12 @@ function isEmptyToolPayload(value: unknown): boolean {
|
|||
return false;
|
||||
}
|
||||
|
||||
function asObjectRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function collectEmptyPayloadExamples(
|
||||
stream: Awaited<ReturnType<BoardTaskLogStreamService['getTaskLogStream']>>
|
||||
): BoardTaskLogDiagnosticExample[] {
|
||||
|
|
@ -194,7 +200,7 @@ function collectEmptyPayloadExamples(
|
|||
});
|
||||
}
|
||||
|
||||
const toolUseResult = message.toolUseResult;
|
||||
const toolUseResult = asObjectRecord(message.toolUseResult);
|
||||
if (!toolUseResult) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<T, R>(
|
|||
if (currentIndex >= items.length) {
|
||||
return;
|
||||
}
|
||||
results[currentIndex] = await fn(items[currentIndex]!);
|
||||
results[currentIndex] = await fn(items[currentIndex]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
|
|
|||
|
|
@ -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<string>;
|
||||
outputMessageUuids: Set<string>;
|
||||
}
|
||||
|
||||
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<string>;
|
||||
}): ToolAnchorScope {
|
||||
const toolUseId =
|
||||
args.candidate.anchor.kind === 'tool' ? args.candidate.anchor.toolUseId : undefined;
|
||||
const assistantUuids = new Set<string>();
|
||||
const outputMessageUuids = new Set<string>();
|
||||
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<string>;
|
||||
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);
|
||||
|
|
|
|||
|
|
@ -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<T, R>(
|
|||
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<ParsedMessage[]> {
|
||||
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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ async function mapLimit<T, R>(
|
|||
if (currentIndex >= items.length) {
|
||||
return;
|
||||
}
|
||||
results[currentIndex] = await fn(items[currentIndex]!);
|
||||
results[currentIndex] = await fn(items[currentIndex]);
|
||||
}
|
||||
});
|
||||
await Promise.all(workers);
|
||||
|
|
|
|||
|
|
@ -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<string, unknown>;
|
||||
resultPayload: unknown;
|
||||
taskRefs: Set<string>;
|
||||
}): 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<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
|
|
@ -516,6 +539,74 @@ function parseJsonLikeString(value: string): unknown {
|
|||
}
|
||||
}
|
||||
|
||||
function formatTaskStatusPayload(payload: Record<string, unknown>): 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, unknown>): string | null {
|
||||
const routing = payload.routing as Record<string, unknown> | undefined;
|
||||
const messageRecord =
|
||||
typeof payload.message === 'object' && payload.message !== null
|
||||
? (payload.message as Record<string, unknown>)
|
||||
: 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<string, unknown>;
|
||||
const normalizedToolName = canonicalizeBoardToolName(toolName) ?? toolName.trim().toLowerCase();
|
||||
const payload = unwrapAgentTeamsResponsePayload(parsedPayload as Record<string, unknown>);
|
||||
if (normalizedToolName === 'task_add_comment' || normalizedToolName === 'task_get_comment') {
|
||||
const comment = payload.comment as Record<string, unknown> | 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<string, unknown> | 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<string, unknown>
|
||||
): Record<string, unknown> {
|
||||
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<string, unknown>)
|
||||
: 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<string> {
|
||||
const allowedNames = new Set<string>();
|
||||
|
||||
|
|
@ -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<string, BoardTaskLogParticipant>();
|
||||
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<string, BoardTaskLogSegment>();
|
||||
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<boolean> {
|
||||
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<BoardTaskLogStreamResponse | null> {
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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<string>([
|
||||
'task_start',
|
||||
|
|
@ -51,6 +61,22 @@ const TASK_LOG_MARKER_TOOL_NAMES = new Set<string>([
|
|||
'review_request_changes',
|
||||
]);
|
||||
|
||||
const BOARD_MCP_TOOL_NAMES = new Set<string>([
|
||||
...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<string>([
|
||||
'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<string>, right: Set<string>): 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<number>,
|
||||
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<number>,
|
||||
messages: ParsedMessage[]
|
||||
): void {
|
||||
const includedAssistantUuids = new Set<string>();
|
||||
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<number>();
|
||||
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,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,5 @@
|
|||
import type { BoardTaskLogStreamResponse } from '@shared/types';
|
||||
|
||||
export interface TaskLogRuntimeStreamSource {
|
||||
getTaskLogStream(teamName: string, taskId: string): Promise<BoardTaskLogStreamResponse | null>;
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -67,12 +67,12 @@ import type {
|
|||
TeamProvisioningModelVerificationMode,
|
||||
TeamProvisioningPrepareResult,
|
||||
TeamProvisioningProgress,
|
||||
TeamWorktreeGitStatus,
|
||||
TeamsAPI,
|
||||
TeamSummary,
|
||||
TeamTask,
|
||||
TeamTaskStatus,
|
||||
TeamViewSnapshot,
|
||||
TeamWorktreeGitStatus,
|
||||
TmuxAPI,
|
||||
TmuxStatus,
|
||||
TriggerTestResult,
|
||||
|
|
|
|||
|
Before Width: | Height: | Size: 82 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 90 KiB After Width: | Height: | Size: 1.7 MiB |
|
Before Width: | Height: | Size: 85 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 74 KiB After Width: | Height: | Size: 1.5 MiB |
|
Before Width: | Height: | Size: 63 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 94 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 46 KiB After Width: | Height: | Size: 1.4 MiB |
|
Before Width: | Height: | Size: 58 KiB After Width: | Height: | Size: 992 KiB |
|
Before Width: | Height: | Size: 65 KiB After Width: | Height: | Size: 1.6 MiB |
|
Before Width: | Height: | Size: 56 KiB After Width: | Height: | Size: 1.4 MiB |