Merge branch 'dev' into feat/opencode-semantic-messaging-seam
# Conflicts: # agent-teams-controller/src/internal/crossTeam.js # mcp-server/src/tools/messageTools.ts # mcp-server/src/tools/taskTools.ts # src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts # src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts # test/main/services/team/OpenCodeProductionE2EEvidence.test.ts # test/main/services/team/OpenCodeProductionGate.live.test.ts # test/main/services/team/OpenCodeReadinessBridge.test.ts
This commit is contained in:
commit
4d1a6149b0
160 changed files with 12412 additions and 5018 deletions
|
|
@ -320,6 +320,7 @@ pnpm dist # macOS + Windows + Linux
|
|||
- [ ] Run terminal commands
|
||||
- [ ] Monitor agents processes/stats
|
||||
- [ ] Reusable agents with SOUL.md
|
||||
- [ ] Сommunicate via messenger
|
||||
|
||||
---
|
||||
|
||||
|
|
|
|||
|
|
@ -48,25 +48,19 @@ function normalizeMetaMembers(rawMembers) {
|
|||
}
|
||||
|
||||
function resolveTargetLead(paths, config) {
|
||||
// 1. config.members — agentType check
|
||||
// 1. config.members - canonical lead detection shared with queue routing
|
||||
if (config && config.members && config.members.length) {
|
||||
const lead = config.members.find((m) => m && m.agentType === 'team-lead');
|
||||
const lead = config.members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
|
||||
if (lead && lead.name) return String(lead.name).trim();
|
||||
|
||||
// 2. config.members — name check
|
||||
const namedLead = config.members.find((m) => m && m.name === 'team-lead');
|
||||
if (namedLead && namedLead.name) return String(namedLead.name).trim();
|
||||
}
|
||||
|
||||
// 3. members.meta.json — WITH normalization (trim + dedup)
|
||||
// 2. members.meta.json - WITH normalization (trim + dedup)
|
||||
const metaPath = path.join(paths.teamDir, 'members.meta.json');
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
||||
const members = normalizeMetaMembers(raw && raw.members);
|
||||
if (members.length > 0) {
|
||||
const metaLead = members.find(
|
||||
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
|
||||
);
|
||||
const metaLead = members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
|
||||
if (metaLead && metaLead.name) return metaLead.name;
|
||||
return members[0].name;
|
||||
}
|
||||
|
|
@ -74,13 +68,8 @@ function resolveTargetLead(paths, config) {
|
|||
/* ENOENT or parse error */
|
||||
}
|
||||
|
||||
// 4. role-based (legacy compat)
|
||||
// 3. First configured member
|
||||
if (config && config.members && config.members.length) {
|
||||
const roleLead = config.members.find(
|
||||
(m) => m && m.role && String(m.role).toLowerCase().includes('lead')
|
||||
);
|
||||
if (roleLead && roleLead.name) return String(roleLead.name).trim();
|
||||
// 5. First member
|
||||
if (config.members[0] && config.members[0].name) return String(config.members[0].name).trim();
|
||||
}
|
||||
|
||||
|
|
@ -165,7 +154,7 @@ function findRecentDuplicate(outboxList, dedupeKey) {
|
|||
function sendCrossTeamMessage(context, flags) {
|
||||
const fromTeam = context.teamName;
|
||||
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
|
||||
const rawFromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead';
|
||||
const rawFromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : '';
|
||||
const replyToConversationId =
|
||||
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
|
||||
const conversationId =
|
||||
|
|
@ -181,6 +170,10 @@ function sendCrossTeamMessage(context, flags) {
|
|||
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
|
||||
throw new Error(`Invalid fromTeam: ${fromTeam}`);
|
||||
}
|
||||
const sourceConfig = runtimeHelpers.readTeamConfig(context.paths);
|
||||
if (!sourceConfig || sourceConfig.deletedAt) {
|
||||
throw new Error(`Source team not found: ${fromTeam}`);
|
||||
}
|
||||
if (!TEAM_NAME_PATTERN.test(toTeam)) {
|
||||
throw new Error(`Invalid toTeam: ${toTeam}`);
|
||||
}
|
||||
|
|
@ -190,14 +183,11 @@ function sendCrossTeamMessage(context, flags) {
|
|||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('Message text is required');
|
||||
}
|
||||
const fromMember = runtimeHelpers.assertExplicitTeamMemberName(
|
||||
context.paths,
|
||||
rawFromMember,
|
||||
'fromMember',
|
||||
{
|
||||
const fromMember = rawFromMember
|
||||
? runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFromMember, 'cross-team sender', {
|
||||
allowLeadAliases: true,
|
||||
}
|
||||
);
|
||||
})
|
||||
: runtimeHelpers.inferLeadName(context.paths);
|
||||
|
||||
// Target context + config
|
||||
const targetContext = createTargetContext(context, toTeam);
|
||||
|
|
|
|||
|
|
@ -101,12 +101,12 @@ function listReviewers(context) {
|
|||
|
||||
function addReviewer(context, reviewer) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
|
||||
const resolvedReviewer = runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
const state = getKanbanState(context);
|
||||
const next = new Set(state.reviewers);
|
||||
next.add(String(reviewer));
|
||||
next.add(String(resolvedReviewer));
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||
...state,
|
||||
reviewers: [...next],
|
||||
|
|
@ -118,7 +118,13 @@ function addReviewer(context, reviewer) {
|
|||
function removeReviewer(context, reviewer) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
const state = getKanbanState(context);
|
||||
const next = state.reviewers.filter((entry) => entry !== reviewer);
|
||||
const resolvedReviewer = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, reviewer, {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
const reviewerNames = new Set(
|
||||
[reviewer, resolvedReviewer].filter((entry) => typeof entry === 'string' && entry.trim())
|
||||
);
|
||||
const next = state.reviewers.filter((entry) => !reviewerNames.has(entry));
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||
...state,
|
||||
reviewers: next,
|
||||
|
|
|
|||
|
|
@ -69,10 +69,9 @@ function normalizeActorKey(value) {
|
|||
function resolveKnownActorName(context, value, label) {
|
||||
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
if (!actor) return null;
|
||||
runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
|
||||
return runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
return actor;
|
||||
}
|
||||
|
||||
function tryResolveKnownActorName(context, value, label) {
|
||||
|
|
@ -301,9 +300,10 @@ function requestReview(context, taskId, flags = {}) {
|
|||
}
|
||||
|
||||
const nextFrom =
|
||||
resolveKnownActorName(context, flags.from, 'review requester') || 'team-lead';
|
||||
resolveKnownActorName(context, flags.from, 'review requester') ||
|
||||
resolveKnownActorName(context, 'team-lead', 'review requester');
|
||||
const rawReviewer = getReviewer(context, flags);
|
||||
const nextReviewer = rawReviewer ? (resolveKnownActorName(context, rawReviewer, 'reviewer'), rawReviewer) : null;
|
||||
const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null;
|
||||
const prevReviewState = getEffectiveReviewState(context, currentTask);
|
||||
if (prevReviewState === 'approved') {
|
||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);
|
||||
|
|
|
|||
|
|
@ -80,19 +80,46 @@ function getEffectiveReviewState(task, kanbanEntry) {
|
|||
return historyState;
|
||||
}
|
||||
|
||||
const persisted = normalizeReviewState(task && task.reviewState);
|
||||
if (persisted !== 'none') {
|
||||
const status = typeof task?.status === 'string' ? task.status.trim() : '';
|
||||
const normalizeFallback = (state, source) => {
|
||||
const normalized = normalizeReviewState(state);
|
||||
if (normalized === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'in_progress' || status === 'deleted') {
|
||||
return {
|
||||
state: persisted,
|
||||
source: 'task_review_state',
|
||||
state: 'none',
|
||||
source: `${source}_status_reset`,
|
||||
};
|
||||
}
|
||||
|
||||
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
|
||||
return {
|
||||
state: normalizeReviewState(kanbanEntry.column),
|
||||
source: 'kanban_column',
|
||||
if (status === 'pending') {
|
||||
return normalized === 'needsFix'
|
||||
? { state: 'needsFix', source: `${source}_pending_needs_fix` }
|
||||
: { state: 'none', source: `${source}_pending_reset` };
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
return normalized === 'review' || normalized === 'approved'
|
||||
? { state: normalized, source }
|
||||
: { state: 'none', source: `${source}_completed_reset` };
|
||||
}
|
||||
|
||||
return { state: normalized, source };
|
||||
};
|
||||
|
||||
const persisted = normalizeReviewState(task && task.reviewState);
|
||||
const persistedFallback = normalizeFallback(persisted, 'task_review_state');
|
||||
if (persistedFallback) {
|
||||
return persistedFallback;
|
||||
}
|
||||
|
||||
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
|
||||
const kanbanFallback = normalizeFallback(kanbanEntry.column, 'kanban_column');
|
||||
if (kanbanFallback) {
|
||||
return kanbanFallback;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const crypto = require('crypto');
|
|||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const LEAD_AGENT_TYPES = new Set(['team-lead', 'lead', 'orchestrator']);
|
||||
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||
'cross_team_send',
|
||||
'cross_team_list_targets',
|
||||
|
|
@ -134,7 +135,7 @@ function isCanonicalLeadMember(member) {
|
|||
const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : '';
|
||||
const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : '';
|
||||
return (
|
||||
agentType === 'team-lead' ||
|
||||
LEAD_AGENT_TYPES.has(agentType) ||
|
||||
name === 'team-lead' ||
|
||||
role === 'team-lead' ||
|
||||
role === 'team lead' ||
|
||||
|
|
@ -179,12 +180,10 @@ function inferLeadName(paths) {
|
|||
const resolved = resolveTeamMembers(paths);
|
||||
const members = resolved.members || [];
|
||||
const lead =
|
||||
members.find(
|
||||
(member) =>
|
||||
member &&
|
||||
typeof member.agentType === 'string' &&
|
||||
member.agentType.trim().toLowerCase() === 'team-lead'
|
||||
) ||
|
||||
members.find((member) => {
|
||||
const agentType = typeof member?.agentType === 'string' ? member.agentType.trim().toLowerCase() : '';
|
||||
return LEAD_AGENT_TYPES.has(agentType);
|
||||
}) ||
|
||||
members.find((member) => String((member && member.name) || '').trim().toLowerCase() === 'team-lead') ||
|
||||
members.find(
|
||||
(member) => {
|
||||
|
|
|
|||
|
|
@ -215,19 +215,23 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
|||
}
|
||||
|
||||
function createTask(context, input) {
|
||||
let taskInput = input;
|
||||
if (input && typeof input.owner === 'string' && input.owner.trim()) {
|
||||
assertKnownTaskActor(context, input.owner, 'task owner');
|
||||
taskInput = {
|
||||
...input,
|
||||
owner: assertKnownTaskActor(context, input.owner, 'task owner'),
|
||||
};
|
||||
}
|
||||
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input));
|
||||
if (input && input.notifyOwner !== false) {
|
||||
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, taskInput));
|
||||
if (taskInput && taskInput.notifyOwner !== false) {
|
||||
maybeNotifyAssignedOwner(context, task, {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
description: taskInput.description,
|
||||
prompt: taskInput.prompt,
|
||||
taskRefs: [
|
||||
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
|
||||
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
|
||||
...(Array.isArray(taskInput.descriptionTaskRefs) ? taskInput.descriptionTaskRefs : []),
|
||||
...(Array.isArray(taskInput.promptTaskRefs) ? taskInput.promptTaskRefs : []),
|
||||
],
|
||||
from: input.from,
|
||||
from: taskInput.from,
|
||||
});
|
||||
}
|
||||
return task;
|
||||
|
|
@ -410,6 +414,10 @@ function softDeleteTask(context, taskId, actor) {
|
|||
|
||||
function restoreTask(context, taskId, actor) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
if (before.status !== 'deleted') {
|
||||
throw new Error(`Task #${before.displayId || before.id} is not deleted; task_restore only restores deleted tasks`);
|
||||
}
|
||||
let task = taskStore.setTaskStatus(context.paths, taskId, 'pending', actor || 'user');
|
||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
if (hasKanbanReference(state, task.id)) {
|
||||
|
|
@ -431,7 +439,7 @@ function setTaskOwner(context, taskId, owner) {
|
|||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
const nextOwner = isClearOwnerValue(owner)
|
||||
? owner
|
||||
: (assertKnownTaskActor(context, owner, 'task owner'), owner);
|
||||
: assertKnownTaskActor(context, owner, 'task owner');
|
||||
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
|
||||
return {
|
||||
previousTask: before,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const AGENT_TEAMS_TASK_TOOL_NAMES = [
|
|||
'task_get_comment',
|
||||
'task_link',
|
||||
'task_list',
|
||||
'task_restore',
|
||||
'task_set_clarification',
|
||||
'task_set_owner',
|
||||
'task_set_status',
|
||||
|
|
|
|||
|
|
@ -447,6 +447,31 @@ describe('agent-teams-controller API', () => {
|
|||
expect(briefing).toContain('Counters: actionable=4, awareness=3');
|
||||
});
|
||||
|
||||
it('treats stale legacy terminal reviewState on pending tasks as owner-ready work', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const staleTask = controller.tasks.createTask({
|
||||
subject: 'Legacy stale approved task',
|
||||
owner: 'bob',
|
||||
status: 'pending',
|
||||
reviewState: 'approved',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
const briefing = await controller.tasks.taskBriefing('bob');
|
||||
const staleLine = briefing.split('\n').find((line) => line.includes(`#${staleTask.displayId}`));
|
||||
expect(staleLine).toContain('[status=pending]');
|
||||
expect(staleLine).not.toContain('review=');
|
||||
expect(staleLine).toContain('reason=owner_ready');
|
||||
|
||||
const rows = controller.tasks.listTaskInventory({ owner: 'bob' });
|
||||
expect(rows.find((row) => row.id === staleTask.id)).toMatchObject({
|
||||
status: 'pending',
|
||||
reviewState: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -1281,6 +1306,82 @@ describe('agent-teams-controller API', () => {
|
|||
expect(leadBriefing).not.toContain(`#${task.displayId}`);
|
||||
});
|
||||
|
||||
it('recognizes lead and orchestrator agent types as canonical team leads', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'my-team',
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [
|
||||
{ name: 'alice', role: 'developer' },
|
||||
{ name: 'leadbot', agentType: 'lead' },
|
||||
{ name: 'opsbot', agentType: 'orchestrator' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const aliceTask = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' });
|
||||
const leadTask = controller.tasks.createTask({ subject: 'Lead owns this', owner: 'leadbot' });
|
||||
const aliceBriefing = await controller.tasks.taskBriefing('alice');
|
||||
const leadBriefing = await controller.tasks.leadBriefing();
|
||||
|
||||
expect(aliceBriefing).toContain(`#${aliceTask.displayId}`);
|
||||
expect(aliceBriefing).toContain('actionOwner=@alice');
|
||||
expect(aliceBriefing).not.toContain(`#${leadTask.displayId}`);
|
||||
expect(leadBriefing).toContain(`#${leadTask.displayId}`);
|
||||
expect(leadBriefing).not.toContain(`#${aliceTask.displayId}`);
|
||||
});
|
||||
|
||||
it('stores canonical member names for lead aliases in owners, reviewers, and reviewer config', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'my-team',
|
||||
members: [
|
||||
{ name: 'leadbot', agentType: 'lead' },
|
||||
{ name: 'alice', role: 'reviewer' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' });
|
||||
expect(leadOwnedTask.owner).toBe('leadbot');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
|
||||
const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' });
|
||||
expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot');
|
||||
|
||||
controller.kanban.addReviewer('lead');
|
||||
expect(controller.kanban.listReviewers()).toEqual(['leadbot']);
|
||||
|
||||
const reviewTask = controller.tasks.createTask({ subject: 'Review alias', owner: 'bob' });
|
||||
controller.tasks.completeTask(reviewTask.id, 'bob');
|
||||
controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'lead' });
|
||||
|
||||
const requested = controller.tasks
|
||||
.getTask(reviewTask.id)
|
||||
.historyEvents.filter((event) => event.type === 'review_requested')
|
||||
.at(-1);
|
||||
expect(requested.reviewer).toBe('leadbot');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects task_briefing for unknown members', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -1472,6 +1573,22 @@ describe('agent-teams-controller API', () => {
|
|||
expect(restored.reviewState).toBe('none');
|
||||
});
|
||||
|
||||
it('rejects task_restore for non-deleted tasks', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
|
||||
expect(() => controller.tasks.restoreTask(task.id, 'alice')).toThrow(
|
||||
'task_restore only restores deleted tasks'
|
||||
);
|
||||
expect(controller.tasks.getTask(task.id).status).toBe('completed');
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
|
||||
});
|
||||
|
||||
it('uses actual kanban overlay for kanbanColumn inventory filters', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
|
|
@ -368,6 +368,108 @@ describe('crossTeam module', () => {
|
|||
expect(fs.existsSync(inboxPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves supported lead agent types before tech-lead role text', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [
|
||||
{ name: 'alice', role: 'tech lead' },
|
||||
{ name: 'olivia', agentType: 'lead' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'olivia.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves orchestrator lead from members.meta.json', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [],
|
||||
},
|
||||
});
|
||||
|
||||
const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json');
|
||||
fs.writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({
|
||||
members: [
|
||||
{ name: 'alice', role: 'tech lead' },
|
||||
{ name: 'orla', agentType: 'orchestrator' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'orla.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects phantom source teams before delivery or outbox writes', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello from nowhere',
|
||||
})
|
||||
).toThrow('Source team not found: team-a');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-a'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown cross-team senders', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
fromMember: 'alicce',
|
||||
text: 'Hello',
|
||||
})
|
||||
).toThrow('Unknown cross-team sender: alicce');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves lead by name fallback', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
|
|
|
|||
2281
docs/team-management/member-liveness-hardening-plan.md
Normal file
2281
docs/team-management/member-liveness-hardening-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -54,11 +54,9 @@ This section records the higher-risk places that were checked after the first dr
|
|||
- The required app-tool proof must cover all teammate-operational tools that `member_briefing` can instruct, not just `message_send` and four task tools.
|
||||
- `claude_team` already exports `AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOL_NAMES` from `agent-teams-controller`; app-side required tools should derive from that instead of duplicating a second list.
|
||||
- Orchestrator direct `mcp:tools/list` proof sees plain MCP names like `message_send`, not OpenCode canonical ids. Do not compare direct stdio results against `agent_teams_message_send` or `agent-teams_message_send`.
|
||||
- However, orchestrator readiness currently exposes `toolProof.observedTools` through `readiness.evidence.observedMcpTools`, and the production live evidence builder reuses that field as `evidence.mcpTools.observedTools`. So direct proof should match plain names internally, but should still emit canonical OpenCode ids for public bridge/evidence output, or add a separate explicit `observedDirectToolNames` field. Do not silently change `observedMcpTools` to plain names.
|
||||
- However, orchestrator readiness currently exposes `toolProof.observedTools` through `readiness.evidence.observedMcpTools`. Direct proof should match plain names internally, but bridge output should keep a clearly named field if canonical OpenCode ids are needed later. Do not silently change `observedMcpTools` semantics.
|
||||
- `agent_teams_orchestrator` does not currently depend on `agent-teams-controller`. Do not import the controller catalog into the orchestrator in v1 unless intentionally adding a new cross-repo/package dependency.
|
||||
- `OpenCodeReadinessBridge.applyProductionE2EGate()` still builds `requiredMcpTools` from runtime-only tools. If app-side readiness starts requiring teammate-operational tools, the production E2E gate must be moved to the same app tool contract or it will validate a weaker/stale artifact.
|
||||
- `assertOpenCodeProductionE2EArtifactGate()` compares expected tool ids against `evidence.mcpTools.observedTools` exactly. Stale evidence generated before this change should fail production mode clearly instead of silently pretending the stronger proof exists.
|
||||
- `scripts/prove-opencode-production.mjs` is only a launcher. The production evidence JSON is built in `test/main/services/team/OpenCodeProductionGate.live.test.ts` inside `buildCandidateEvidence()`. Updating the script alone does nothing; update the live test builder and gate expectations.
|
||||
- The old project-proof gate was removed from OpenCode launch readiness. Do not reintroduce project-scoped launch blocking for selected models; runtime readiness should be based on inventory, capabilities, runtime stores, app MCP tool proof, and the execution probe.
|
||||
- Current controller teammate-operational catalog includes more than the obvious message/task-start tools: `task_attach_comment_file`, `task_attach_file`, `task_create`, `task_create_from_message`, `task_link`, and `task_unlink` are also teammate-operational and must be included in any explicit orchestrator v1 list.
|
||||
- `mcp-server/src/agent-teams-controller.d.ts` and `src/types/agent-teams-controller.d.ts` mirror controller signatures and must be updated when `memberBriefing(memberName, options)` is added.
|
||||
- `agent-teams-controller` is CommonJS and existing TS code imports it as `import * as agentTeamsControllerModule from 'agent-teams-controller'`; use that pattern in new app-side imports instead of assuming a default ESM export.
|
||||
|
|
@ -190,13 +188,10 @@ Implementation rule:
|
|||
- `/Users/belief/dev/projects/claude/claude_team/src/types/agent-teams-controller.d.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/delivery/RuntimeDeliveryService.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/OpenCodeTaskLogStreamSource.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/test/main/services/team/OpenCodeProductionGate.live.test.ts`
|
||||
- `/Users/belief/dev/projects/claude/claude_team/scripts/prove-opencode-production.mjs`
|
||||
|
||||
`agent_teams_orchestrator` files:
|
||||
|
||||
|
|
@ -222,7 +217,9 @@ Example:
|
|||
|
||||
```js
|
||||
function normalizeRuntimeProvider(value) {
|
||||
const normalized = String(value || '').trim().toLowerCase();
|
||||
const normalized = String(value || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return normalized === 'opencode' ? 'opencode' : 'native';
|
||||
}
|
||||
|
||||
|
|
@ -267,7 +264,9 @@ function createMemberMessagingProtocol(runtimeProvider) {
|
|||
}
|
||||
|
||||
function isOpenCodeMember(member) {
|
||||
const provider = String(member?.providerId || member?.provider || '').trim().toLowerCase();
|
||||
const provider = String(member?.providerId || member?.provider || '')
|
||||
.trim()
|
||||
.toLowerCase();
|
||||
return provider === 'opencode';
|
||||
}
|
||||
|
||||
|
|
@ -302,9 +301,7 @@ Edit pattern:
|
|||
|
||||
```js
|
||||
function copyTrimmedString(member, key) {
|
||||
return typeof member[key] === 'string' && member[key].trim()
|
||||
? { [key]: member[key].trim() }
|
||||
: {};
|
||||
return typeof member[key] === 'string' && member[key].trim() ? { [key]: member[key].trim() } : {};
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -314,9 +311,15 @@ Then preserve fields:
|
|||
return {
|
||||
name,
|
||||
...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}),
|
||||
...(typeof member.workflow === 'string' && member.workflow.trim() ? { workflow: member.workflow.trim() } : {}),
|
||||
...(typeof member.agentType === 'string' && member.agentType.trim() ? { agentType: member.agentType.trim() } : {}),
|
||||
...(typeof member.color === 'string' && member.color.trim() ? { color: member.color.trim() } : {}),
|
||||
...(typeof member.workflow === 'string' && member.workflow.trim()
|
||||
? { workflow: member.workflow.trim() }
|
||||
: {}),
|
||||
...(typeof member.agentType === 'string' && member.agentType.trim()
|
||||
? { agentType: member.agentType.trim() }
|
||||
: {}),
|
||||
...(typeof member.color === 'string' && member.color.trim()
|
||||
? { color: member.color.trim() }
|
||||
: {}),
|
||||
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
|
||||
...copyTrimmedString(member, 'providerId'),
|
||||
...copyTrimmedString(member, 'providerBackendId'),
|
||||
|
|
@ -398,7 +401,8 @@ Inside the function:
|
|||
|
||||
```js
|
||||
const explicitRuntimeProvider = options.runtimeProvider;
|
||||
const inferredRuntimeProvider = explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native');
|
||||
const inferredRuntimeProvider =
|
||||
explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native');
|
||||
const messagingProtocol = createMemberMessagingProtocol(inferredRuntimeProvider);
|
||||
```
|
||||
|
||||
|
|
@ -702,13 +706,11 @@ Do not hide `runtime_deliver_message` from readiness or app tool availability pr
|
|||
Instead, make tool descriptions and OpenCode prompts explicitly route normal replies:
|
||||
|
||||
```ts
|
||||
description:
|
||||
'Send a visible team/user message. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name.'
|
||||
description: 'Send a visible team/user message. OpenCode teammates should use this for normal replies to the human user, lead, or same-team teammates. When to is "user", from is required and must be your configured teammate name.';
|
||||
```
|
||||
|
||||
```ts
|
||||
description:
|
||||
'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.'
|
||||
description: 'Low-level OpenCode runtime delivery journal tool. Use only when the runtime/app prompt explicitly provides runId, runtimeSessionId, idempotencyKey, and asks for runtime delivery. For normal visible replies, use message_send.';
|
||||
```
|
||||
|
||||
OpenCode-specific prompt wording should avoid generic "deliver message" language:
|
||||
|
|
@ -778,7 +780,9 @@ function normalizeMessageSendFlags(context, flags) {
|
|||
throw new Error('message_send cannot target another team. Use cross_team_send with toTeam.');
|
||||
}
|
||||
if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamToolRecipient?.(rawTo)) {
|
||||
throw new Error('message_send cannot target cross_team_send. Use cross_team_send with toTeam.');
|
||||
throw new Error(
|
||||
'message_send cannot target cross_team_send. Use cross_team_send with toTeam.'
|
||||
);
|
||||
}
|
||||
if (!resolvedTo) {
|
||||
throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`);
|
||||
|
|
@ -903,7 +907,7 @@ Current risk:
|
|||
`TeamProvisioningService.captureSendMessages()` recognizes only:
|
||||
|
||||
```ts
|
||||
part.name === 'mcp__agent-teams__message_send'
|
||||
part.name === 'mcp__agent-teams__message_send';
|
||||
```
|
||||
|
||||
But OpenCode and MCP tooling can expose names as:
|
||||
|
|
@ -957,8 +961,7 @@ export function isAgentTeamsToolUse(input: {
|
|||
}
|
||||
|
||||
const hasKnownPrefix =
|
||||
rawName !== canonical ||
|
||||
AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix));
|
||||
rawName !== canonical || AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix));
|
||||
if (hasKnownPrefix) {
|
||||
return true;
|
||||
}
|
||||
|
|
@ -1078,23 +1081,23 @@ Add a helper:
|
|||
|
||||
```ts
|
||||
function buildOpenCodeRuntimeIdentityBlock(input: {
|
||||
teamName: string
|
||||
memberName: string
|
||||
runId: string
|
||||
runtimeSessionId: string
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
runId: string;
|
||||
runtimeSessionId: string;
|
||||
}): string {
|
||||
const checkinPayload = {
|
||||
teamName: input.teamName,
|
||||
runId: input.runId,
|
||||
memberName: input.memberName,
|
||||
runtimeSessionId: input.runtimeSessionId,
|
||||
}
|
||||
};
|
||||
|
||||
const briefingPayload = {
|
||||
teamName: input.teamName,
|
||||
memberName: input.memberName,
|
||||
runtimeProvider: 'opencode',
|
||||
}
|
||||
};
|
||||
|
||||
return [
|
||||
'<opencode_runtime_identity>',
|
||||
|
|
@ -1105,7 +1108,7 @@ function buildOpenCodeRuntimeIdentityBlock(input: {
|
|||
`Call the exposed Agent Teams member_briefing tool, usually agent-teams_member_briefing or mcp__agent-teams__member_briefing, with: ${JSON.stringify(briefingPayload)}`,
|
||||
'For visible team/app messages, use the exposed Agent Teams message_send tool, usually agent-teams_message_send or mcp__agent-teams__message_send. Do not use SendMessage.',
|
||||
'</opencode_runtime_identity>',
|
||||
].join('\n')
|
||||
].join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1117,12 +1120,12 @@ const runtimeIdentityBlock = buildOpenCodeRuntimeIdentityBlock({
|
|||
memberName: name,
|
||||
runId,
|
||||
runtimeSessionId: record.opencodeSessionId,
|
||||
})
|
||||
});
|
||||
|
||||
await openCodeSessionBridge.promptAsync(record, {
|
||||
text: `${runtimeIdentityBlock}\n\n${prompt}`,
|
||||
agent: 'teammate',
|
||||
})
|
||||
});
|
||||
```
|
||||
|
||||
Then add a bounded launch-settle helper before mapping the member as final `created`/`confirmed_alive`.
|
||||
|
|
@ -1512,7 +1515,9 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
|||
'</opencode_app_message_delivery>',
|
||||
'',
|
||||
input.text,
|
||||
].filter((line): line is string => line !== null).join('\n');
|
||||
]
|
||||
.filter((line): line is string => line !== null)
|
||||
.join('\n');
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1672,7 +1677,7 @@ sendTeamMessage: async (teamName, request) => {
|
|||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Update call sites so pending-reply state reflects actual delivery truth:
|
||||
|
|
@ -1904,7 +1909,7 @@ const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [
|
|||
'runtime_deliver_message',
|
||||
'runtime_task_event',
|
||||
'runtime_heartbeat',
|
||||
] as const
|
||||
] as const;
|
||||
```
|
||||
|
||||
Change to route-specific direct MCP names.
|
||||
|
|
@ -1913,10 +1918,10 @@ Important:
|
|||
|
||||
- `Client.listTools()` returns plain names such as `message_send`.
|
||||
- Do not prefix direct stdio results with `agent-teams_`.
|
||||
- Only OpenCode app/API tool-id proof and production E2E evidence should deal with canonical ids like `agent-teams_message_send`.
|
||||
- Only OpenCode app/API tool-id proof should deal with canonical ids like `agent-teams_message_send`.
|
||||
- `agent_teams_message_send` is an accepted alias, not the canonical id produced by `buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')`.
|
||||
- The orchestrator explicit list should be treated as a boundary adapter, not as the source of truth for app-side UI/readiness.
|
||||
- `readiness.evidence.observedMcpTools` is already consumed by production evidence as OpenCode tool ids. Keep that public field canonical. If direct proof needs plain diagnostics, add a second private/internal field such as `observedDirectToolNames`.
|
||||
- Keep `readiness.evidence.observedMcpTools` canonical if it is exposed through the bridge. If direct proof needs plain diagnostics, add a second private/internal field such as `observedDirectToolNames`.
|
||||
|
||||
```ts
|
||||
const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
|
||||
|
|
@ -1924,7 +1929,7 @@ const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
|
|||
'runtime_deliver_message',
|
||||
'runtime_task_event',
|
||||
'runtime_heartbeat',
|
||||
] as const
|
||||
] as const;
|
||||
|
||||
const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [
|
||||
'member_briefing',
|
||||
|
|
@ -1956,20 +1961,20 @@ const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [
|
|||
'cross_team_send',
|
||||
'cross_team_list_targets',
|
||||
'cross_team_get_outbox',
|
||||
] as const
|
||||
] as const;
|
||||
|
||||
const REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES = [
|
||||
...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS,
|
||||
...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS,
|
||||
] as const
|
||||
] as const;
|
||||
```
|
||||
|
||||
Update direct listTools mapping:
|
||||
|
||||
```ts
|
||||
return (result.tools ?? [])
|
||||
.map(tool => tool.name)
|
||||
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0)
|
||||
.map((tool) => tool.name)
|
||||
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0);
|
||||
```
|
||||
|
||||
Compare plain names internally:
|
||||
|
|
@ -1988,14 +1993,14 @@ But emit canonical ids for bridge readiness/evidence:
|
|||
|
||||
```ts
|
||||
function buildOpenCodeCanonicalMcpToolId(toolName: string): string {
|
||||
return `${OPEN_CODE_APP_MCP_SERVER_NAME}_${toolName}`
|
||||
return `${OPEN_CODE_APP_MCP_SERVER_NAME}_${toolName}`;
|
||||
}
|
||||
|
||||
function matchAppMcpTools(observedDirectToolNames: string[], route: string): AppMcpToolProof {
|
||||
const observedDirect = new Set(observedDirectToolNames)
|
||||
const observedDirect = new Set(observedDirectToolNames);
|
||||
const missingTools = REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES.filter(
|
||||
tool => !observedDirect.has(tool)
|
||||
)
|
||||
(tool) => !observedDirect.has(tool)
|
||||
);
|
||||
|
||||
return {
|
||||
ok: missingTools.length === 0,
|
||||
|
|
@ -2004,11 +2009,12 @@ function matchAppMcpTools(observedDirectToolNames: string[], route: string): App
|
|||
),
|
||||
observedDirectToolNames: uniqueSortedStrings(observedDirectToolNames),
|
||||
missingTools,
|
||||
diagnostics: missingTools.length === 0
|
||||
diagnostics:
|
||||
missingTools.length === 0
|
||||
? []
|
||||
: [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`],
|
||||
route,
|
||||
}
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -2093,96 +2099,26 @@ Acceptance:
|
|||
- Existing callers that truly mean runtime schema tools should use `REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS`, not the full app list.
|
||||
- Existing callers that mean launch-visible app tools should use `REQUIRED_AGENT_TEAMS_APP_TOOLS` or `REQUIRED_AGENT_TEAMS_APP_TOOL_IDS`.
|
||||
|
||||
### Step 12 - Update production E2E gate to prove the same app tools
|
||||
### Step 12 - Keep app tool proof in readiness only
|
||||
|
||||
Files:
|
||||
|
||||
```text
|
||||
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/bridge/OpenCodeReadinessBridge.ts
|
||||
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/e2e/OpenCodeProductionE2EEvidence.ts
|
||||
/Users/belief/dev/projects/claude/claude_team/test/main/services/team/OpenCodeProductionGate.live.test.ts
|
||||
/Users/belief/dev/projects/claude/claude_team/scripts/prove-opencode-production.mjs
|
||||
/Users/belief/dev/projects/claude/claude_team/src/main/services/team/opencode/mcp/OpenCodeMcpToolAvailability.ts
|
||||
```
|
||||
|
||||
Current risk:
|
||||
Current rule:
|
||||
|
||||
```ts
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
)
|
||||
```
|
||||
|
||||
That is weaker than the planned app-tool readiness proof. It can create two bad states:
|
||||
|
||||
- Production gate passes an artifact that proves runtime tools only, while OpenCode can still miss `message_send` or `member_briefing`.
|
||||
- Production gate fails confusingly after the required list changes because old evidence was generated with the weaker runtime-only set.
|
||||
|
||||
Change `OpenCodeReadinessBridge.applyProductionE2EGate()` to use the full app tool id list:
|
||||
|
||||
```ts
|
||||
import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
// ...
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
```
|
||||
|
||||
Update the live evidence builder in `OpenCodeProductionGate.live.test.ts`.
|
||||
|
||||
Current builder path:
|
||||
|
||||
```ts
|
||||
mcpTools: {
|
||||
requiredTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
),
|
||||
observedTools: input.readinessObservedTools,
|
||||
},
|
||||
```
|
||||
|
||||
Change it to:
|
||||
|
||||
```ts
|
||||
mcpTools: {
|
||||
requiredTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
observedTools: input.readinessObservedTools,
|
||||
},
|
||||
```
|
||||
|
||||
Guard the shape explicitly because `input.readinessObservedTools` comes from the orchestrator bridge:
|
||||
|
||||
```ts
|
||||
const missingObservedAppToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS.filter(
|
||||
(toolId) => !input.readinessObservedTools.includes(toolId)
|
||||
);
|
||||
expect(missingObservedAppToolIds).toEqual([]);
|
||||
```
|
||||
|
||||
If that assertion fails after Step 10, the orchestrator is probably returning plain direct tool names in `readiness.evidence.observedMcpTools`. Fix the bridge output instead of weakening production evidence.
|
||||
|
||||
Keep the evidence validator exact:
|
||||
|
||||
```ts
|
||||
const observedTools = new Set(evidence.mcpTools.observedTools);
|
||||
const missingTools = requiredTools.filter((tool) => !observedTools.has(tool));
|
||||
```
|
||||
|
||||
Do not add alias fallback here. Evidence should prove the canonical OpenCode app ids that production readiness expects. Alias tolerance belongs in transcript/capture parsing, not in production artifact gating.
|
||||
|
||||
Update live/evidence test fixtures:
|
||||
|
||||
```ts
|
||||
const requiredToolIds = REQUIRED_AGENT_TEAMS_APP_TOOL_IDS;
|
||||
```
|
||||
|
||||
`scripts/prove-opencode-production.mjs` should not need evidence JSON logic changes because it only launches the live test. Do update its console copy only if it refers to runtime-only proof. The real acceptance point is the artifact written by `OpenCodeProductionGate.live.test.ts`.
|
||||
- OpenCode launch readiness should not require a project-scoped proof artifact.
|
||||
- App tool proof belongs in the live readiness path through capability, runtime-store, MCP tool, and execution checks.
|
||||
- If tool requirements change, update `OpenCodeMcpToolAvailability` and readiness tests directly.
|
||||
|
||||
Acceptance:
|
||||
|
||||
- Production evidence must include canonical ids for runtime and teammate-operational tools.
|
||||
- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are explicitly covered by tests.
|
||||
- Old runtime-only evidence fails production mode with a clear diagnostic listing missing app MCP tools.
|
||||
- Dogfood mode can still warn instead of blocking if current policy already allows degraded evidence there.
|
||||
- No schema validation is added for all operational tools in this step. That would be a separate hardening pass.
|
||||
- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are covered by readiness tests.
|
||||
- Missing app MCP tools fails readiness directly with a clear diagnostic.
|
||||
- No project-specific artifact is required to create or launch a team.
|
||||
|
||||
### Step 13 - Resolve secondary lane current-run evidence from lane manifest
|
||||
|
||||
|
|
@ -2268,10 +2204,7 @@ currentRunId: this.getCurrentOpenCodeRuntimeRunId(input.teamName, input.laneId),
|
|||
Change to async durable resolution:
|
||||
|
||||
```ts
|
||||
const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId(
|
||||
input.teamName,
|
||||
input.laneId
|
||||
);
|
||||
const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId(input.teamName, input.laneId);
|
||||
|
||||
await store.assertEvidenceAccepted({
|
||||
teamName: input.teamName,
|
||||
|
|
@ -2384,7 +2317,7 @@ emit: (event) => {
|
|||
teamName: event.teamName,
|
||||
detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined,
|
||||
});
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Reason:
|
||||
|
|
@ -2422,7 +2355,7 @@ Do not add a frontend fake "agent answered" path. Frontend may show "message sav
|
|||
These are the places most likely to produce regressions if implemented casually.
|
||||
|
||||
1. Canonical OpenCode MCP id spelling - 🎯 8 🛡️ 8 🧠 3, about 20-50 LOC in tests
|
||||
`buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')` keeps the dash in `agent-teams_message_send`. Direct MCP stdio proof uses plain `message_send`. Transcript parsing accepts aliases. Production E2E evidence should use the canonical dash form only. Add tests for all three contexts so nobody normalizes everything to underscore by accident.
|
||||
`buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')` keeps the dash in `agent-teams_message_send`. Direct MCP stdio proof uses plain `message_send`. Transcript parsing accepts aliases. Add tests for these contexts so nobody normalizes everything to underscore by accident.
|
||||
|
||||
2. Orchestrator explicit teammate tool list drift - 🎯 7 🛡️ 7 🧠 4, about 40-90 LOC in tests
|
||||
The v1 orchestrator list is duplicated by design to avoid adding a dependency. This is acceptable only if tests cover the current controller teammate-operational catalog snapshot, including attachment/link/create/cross-team tools. If this fails repeatedly, move to Option C from Step 10: generated shared protocol contract.
|
||||
|
|
@ -2547,7 +2480,9 @@ it('persists taskRefs through message_send', async () => {
|
|||
taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }],
|
||||
});
|
||||
|
||||
const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'user.json'), 'utf8'));
|
||||
const rows = JSON.parse(
|
||||
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'user.json'), 'utf8')
|
||||
);
|
||||
expect(rows[0].taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]);
|
||||
});
|
||||
```
|
||||
|
|
@ -2574,7 +2509,9 @@ it('keeps legacy user-to-member message_send valid without from', async () => {
|
|||
text: 'Please check this',
|
||||
});
|
||||
|
||||
const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8'));
|
||||
const rows = JSON.parse(
|
||||
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')
|
||||
);
|
||||
expect(rows.at(-1)).toMatchObject({
|
||||
from: 'user',
|
||||
to: 'alice',
|
||||
|
|
@ -2609,7 +2546,9 @@ it('canonicalizes message_send lead aliases before writing inbox files', async (
|
|||
});
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'lead.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'team-lead.json'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'team-lead.json'))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -2624,7 +2563,9 @@ it('canonicalizes message_send sender aliases before persistence', async () => {
|
|||
text: 'Please review',
|
||||
});
|
||||
|
||||
const rows = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8'));
|
||||
const rows = JSON.parse(
|
||||
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'alice.json'), 'utf8')
|
||||
);
|
||||
expect(rows.at(-1)).toMatchObject({ from: 'lead', to: 'alice' });
|
||||
});
|
||||
```
|
||||
|
|
@ -2682,7 +2623,9 @@ it('keeps configured dotted local members valid before applying cross-team heuri
|
|||
text: 'Local dotted member',
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'qa.bot.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'qa.bot.json'))).toBe(
|
||||
true
|
||||
);
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -2717,11 +2660,18 @@ it('persists taskRefs through cross_team_send when enabled', async () => {
|
|||
});
|
||||
|
||||
const targetInbox = JSON.parse(
|
||||
fs.readFileSync(path.join(claudeDir, 'teams', 'review-team', 'inboxes', 'team-lead.json'), 'utf8')
|
||||
fs.readFileSync(
|
||||
path.join(claudeDir, 'teams', 'review-team', 'inboxes', 'team-lead.json'),
|
||||
'utf8'
|
||||
)
|
||||
);
|
||||
expect(targetInbox.at(-1).taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]);
|
||||
expect(targetInbox.at(-1).taskRefs).toEqual([
|
||||
{ teamName, taskId: 'task-1', displayId: 'abcd1234' },
|
||||
]);
|
||||
|
||||
const outbox = JSON.parse(fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'sent-cross-team.json'), 'utf8'));
|
||||
const outbox = JSON.parse(
|
||||
fs.readFileSync(path.join(claudeDir, 'teams', teamName, 'sent-cross-team.json'), 'utf8')
|
||||
);
|
||||
expect(outbox.at(-1).taskRefs).toEqual([{ teamName, taskId: 'task-1', displayId: 'abcd1234' }]);
|
||||
});
|
||||
```
|
||||
|
|
@ -2812,70 +2762,19 @@ it('does not classify unrelated plain message_send tool_use without Agent Teams
|
|||
});
|
||||
```
|
||||
|
||||
Add app-side OpenCode readiness/evidence tests:
|
||||
Add app-side OpenCode readiness tests:
|
||||
|
||||
```ts
|
||||
it('uses full app tool ids for OpenCode production E2E gate expectations', async () => {
|
||||
const evidence = buildEvidence({
|
||||
observedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
});
|
||||
|
||||
it('uses full app tool ids for OpenCode readiness expectations', async () => {
|
||||
const result = await bridge.runReadiness({
|
||||
launchMode: 'production',
|
||||
selectedModel: 'minimax-m2.5-free',
|
||||
// ...
|
||||
});
|
||||
|
||||
expect(result.supportLevel).toBe('production_supported');
|
||||
expect(productionE2eEvidence.read).toHaveBeenCalled();
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
it('rejects stale runtime-only OpenCode production evidence', async () => {
|
||||
const runtimeOnlyToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS.map((tool) =>
|
||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
||||
expect(result.evidence.observedMcpTools).toEqual(
|
||||
expect.arrayContaining(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS)
|
||||
);
|
||||
const evidence = buildEvidence({
|
||||
observedTools: runtimeOnlyToolIds,
|
||||
requiredTools: runtimeOnlyToolIds,
|
||||
});
|
||||
|
||||
const gate = assertOpenCodeProductionE2EArtifactGate({
|
||||
evidence,
|
||||
artifactPath: '/tmp/opencode-e2e.json',
|
||||
expected: {
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
},
|
||||
});
|
||||
|
||||
expect(gate.ok).toBe(false);
|
||||
expect(gate.diagnostics.join('\n')).toContain('agent-teams_message_send');
|
||||
expect(gate.diagnostics.join('\n')).toContain('agent-teams_member_briefing');
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
it('live production evidence builder writes full app tool ids', () => {
|
||||
const evidence = buildCandidateEvidence({
|
||||
readinessObservedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
// other required live-test fields
|
||||
});
|
||||
|
||||
expect(evidence.mcpTools.requiredTools).toEqual(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS);
|
||||
expect(evidence.mcpTools.observedTools).toContain('agent-teams_message_send');
|
||||
expect(evidence.mcpTools.observedTools).toContain('agent-teams_cross_team_send');
|
||||
});
|
||||
```
|
||||
|
||||
```ts
|
||||
it('live production evidence builder rejects plain direct tool names for artifact output', () => {
|
||||
expect(() =>
|
||||
buildCandidateEvidence({
|
||||
readinessObservedTools: ['message_send', 'member_briefing'],
|
||||
// other required live-test fields
|
||||
})
|
||||
).toThrow(/agent-teams_message_send/);
|
||||
});
|
||||
```
|
||||
|
||||
|
|
@ -3437,7 +3336,7 @@ Run targeted tests first:
|
|||
cd /Users/belief/dev/projects/claude/claude_team
|
||||
pnpm --filter agent-teams-controller test -- test/controller.test.js
|
||||
pnpm --filter agent-teams-mcp test -- test/tools.test.ts
|
||||
pnpm vitest run test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/services/team/TeamProvisioningServiceRelay.test.ts test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts test/main/ipc/teams.test.ts test/main/services/team/OpenCodeMcpToolAvailability.test.ts test/main/services/team/OpenCodeReadinessBridge.test.ts test/main/services/team/OpenCodeProductionE2EEvidence.test.ts test/renderer/store/teamChangeThrottle.test.ts test/renderer/store/teamSlice.test.ts test/renderer/components/team/messages/MessagesPanel.test.ts test/renderer/components/team/dialogs/SendMessageDialog.test.tsx
|
||||
pnpm vitest run test/main/services/team/OpenCodeTeamRuntimeAdapter.test.ts test/main/services/team/TeamProvisioningService.test.ts test/main/services/team/TeamProvisioningServiceRelay.test.ts test/main/services/team/TeamProvisioningServiceLiveMessages.test.ts test/main/ipc/teams.test.ts test/main/services/team/OpenCodeMcpToolAvailability.test.ts test/main/services/team/OpenCodeReadinessBridge.test.ts test/renderer/store/teamChangeThrottle.test.ts test/renderer/store/teamSlice.test.ts test/renderer/components/team/messages/MessagesPanel.test.ts test/renderer/components/team/dialogs/SendMessageDialog.test.tsx
|
||||
```
|
||||
|
||||
```bash
|
||||
|
|
@ -3499,7 +3398,7 @@ Avoid heavy E2E until targeted tests pass.
|
|||
19. Add OpenCode-targeted inbox runtime relay with dedupe/read marking.
|
||||
20. Expand orchestrator direct MCP proof with the explicit plain-name adapter list while keeping public observed evidence as canonical OpenCode ids.
|
||||
21. Expand app-side OpenCode MCP availability proof from controller catalog.
|
||||
22. Update production E2E gate and evidence fixtures to require the full app tool id list.
|
||||
22. Keep OpenCode readiness requiring the full app tool id list without project-scoped artifacts.
|
||||
23. Add lane-scoped manifest `activeRunId` recovery and consume it in evidence acceptance/message delivery/runtime delivery service.
|
||||
24. Add runtime delivery `TeamChangeEvent.detail` adapter guard tests.
|
||||
25. Add tests.
|
||||
|
|
@ -3534,9 +3433,7 @@ Avoid heavy E2E until targeted tests pass.
|
|||
- Readiness passes while `message_send` is missing. This means proof list is still incomplete.
|
||||
- Readiness passes while review/process/task-set tools are missing. This means proof only checked a small subset instead of all teammate-operational briefing tools.
|
||||
- Direct MCP readiness fails even though `tools/list` contains `message_send`. This usually means direct stdio proof is incorrectly comparing plain names against OpenCode canonical ids.
|
||||
- Production live evidence fails after direct MCP proof succeeds. This usually means the orchestrator started exposing plain names in `readiness.evidence.observedMcpTools`; keep plain names internal and expose canonical `agent-teams_*` ids there.
|
||||
- Production mode passes with runtime-only evidence. This means `OpenCodeReadinessBridge` still uses `REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS` instead of the full app tool id list.
|
||||
- Production mode blocks with stale evidence after this change. That is expected until the OpenCode production E2E artifact is regenerated, but the diagnostic must list the missing app tools clearly.
|
||||
- Readiness passes with runtime-only app tool coverage. This means `OpenCodeMcpToolAvailability` still uses only runtime tools instead of the full app tool id list.
|
||||
- App-side and orchestrator required tool lists drift. For v1, this is controlled by tests and explicit comments. If drift keeps recurring, move to a generated shared contract artifact.
|
||||
- OpenCode member stays `created` even though the prompt was accepted. This usually means `promptAsync()` was reconciled too early; use the bounded launch-settle helper before final launch mapping.
|
||||
- Preview observation times out and marks a teammate failed. That is wrong. Preview timeout should only fall back to reconcile and keep the member pending.
|
||||
|
|
@ -3557,7 +3454,6 @@ Avoid heavy E2E until targeted tests pass.
|
|||
- OpenCode launch, briefing, assignment, completion, and clarification instructions consistently use `agent-teams_message_send`.
|
||||
- OpenCode cross-team instructions consistently use `agent-teams_cross_team_send`, not `message_send`.
|
||||
- OpenCode readiness fails if required app MCP tools are absent.
|
||||
- OpenCode production E2E gate proves the same app MCP tools that readiness requires.
|
||||
- Orchestrator direct proof matches plain MCP names internally and emits canonical OpenCode ids in readiness evidence.
|
||||
- Runtime tool descriptions make `message_send` the normal visible reply API and keep `runtime_deliver_message` scoped to explicit low-level runtime delivery flows.
|
||||
- OpenCode can prove liveness through `runtime_bootstrap_checkin`.
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -42,8 +43,9 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
replyToConversationId,
|
||||
taskRefs,
|
||||
chainDepth,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
||||
toTeam,
|
||||
|
|
@ -56,7 +58,8 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(chainDepth !== undefined ? { chainDepth } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -66,14 +69,16 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
excludeTeam: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, excludeTeam }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, excludeTeam }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
|
||||
...(excludeTeam ? { excludeTeam } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -82,9 +87,11 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -16,8 +17,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())),
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState()));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -29,10 +32,12 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
column: z.enum(['review', 'approved']),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, column }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, column }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -43,8 +48,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))),
|
||||
execute: async ({ teamName, claudeDir, taskId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId)));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -53,8 +60,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())),
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers()));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -64,8 +73,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
reviewer: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer)));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -75,9 +86,11 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
reviewer: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, reviewer }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -20,13 +21,16 @@ export function registerLeadTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) => ({
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
|
||||
const toolContextSchema = {
|
||||
|
|
@ -53,8 +54,9 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
leadSessionId,
|
||||
attachments,
|
||||
taskRefs,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).messages.sendMessage({
|
||||
to,
|
||||
|
|
@ -67,6 +69,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(taskRefs?.length ? { taskRefs } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -34,8 +35,9 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
port,
|
||||
url,
|
||||
claudeProcessId,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).processes.registerProcess({
|
||||
pid,
|
||||
|
|
@ -47,7 +49,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -57,10 +60,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -71,10 +76,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, pid }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -85,9 +92,11 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, pid }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent, slimTask } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -20,8 +21,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
reviewer: z.string().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.requestReview(taskId, {
|
||||
|
|
@ -31,7 +33,8 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -42,14 +45,16 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.startReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -63,8 +68,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
notifyOwner: z.boolean().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||
|
|
@ -75,7 +81,8 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -88,8 +95,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
comment: z.string().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
||||
|
|
@ -99,6 +107,7 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -57,8 +58,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
worktree,
|
||||
extraCliArgs,
|
||||
waitForReady,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.launchTeam({
|
||||
cwd,
|
||||
...(prompt ? { prompt } : {}),
|
||||
|
|
@ -72,7 +74,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
...(waitForReady !== undefined ? { waitForReady } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -82,14 +85,16 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
waitForStop: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) =>
|
||||
jsonTextContent(
|
||||
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.stopTeam({
|
||||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
...(waitForStop !== undefined ? { waitForStop } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -112,8 +117,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
observedAt,
|
||||
diagnostics,
|
||||
metadata,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({
|
||||
runId,
|
||||
memberName,
|
||||
|
|
@ -124,7 +130,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -157,8 +164,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
createdAt,
|
||||
summary,
|
||||
taskRefs,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({
|
||||
idempotencyKey,
|
||||
runId,
|
||||
|
|
@ -172,7 +180,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -204,8 +213,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
createdAt,
|
||||
summary,
|
||||
metadata,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeTaskEvent({
|
||||
idempotencyKey,
|
||||
runId,
|
||||
|
|
@ -219,7 +229,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -242,8 +253,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
observedAt,
|
||||
status,
|
||||
metadata,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeHeartbeat({
|
||||
runId,
|
||||
memberName,
|
||||
|
|
@ -254,6 +266,7 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { agentBlocks, getController } from '../controller';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
|
||||
|
||||
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */
|
||||
|
|
@ -70,42 +69,6 @@ function buildCreateTaskPayload(params: {
|
|||
};
|
||||
}
|
||||
|
||||
function resolveConfigPath(teamName: string, claudeDir?: string): string {
|
||||
const controller = getController(teamName, claudeDir) as {
|
||||
context?: { paths?: { teamDir?: string } };
|
||||
};
|
||||
const teamDir = controller.context?.paths?.teamDir;
|
||||
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
return path.join(teamDir, 'config.json');
|
||||
}
|
||||
|
||||
function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
|
||||
const configPath = resolveConfigPath(teamName, claudeDir);
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'task_create',
|
||||
|
|
@ -288,8 +251,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))),
|
||||
execute: async ({ teamName, claudeDir, taskId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -301,12 +268,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
commentId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, commentId }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId)
|
||||
)
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, commentId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -333,8 +300,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
relatedTo,
|
||||
blockedBy,
|
||||
limit,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.listTaskInventory({
|
||||
...(owner ? { owner } : {}),
|
||||
|
|
@ -346,7 +314,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
limit: normalizeTaskListLimit(limit),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -358,10 +327,42 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_restore',
|
||||
description: 'Restore a deleted task back to pending work state',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.restoreTask(taskId, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -372,8 +373,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
await Promise.resolve(jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<string, unknown>))),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -384,10 +396,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -398,10 +419,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
owner: z.string().nullable(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, owner }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, owner }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -413,8 +443,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
text: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, text, from }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, text, from }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
||||
|
|
@ -423,7 +454,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -448,8 +480,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
filename,
|
||||
mimeType,
|
||||
noFallback,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
||||
|
|
@ -461,7 +494,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -488,8 +522,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
filename,
|
||||
mimeType,
|
||||
noFallback,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
||||
|
|
@ -501,7 +536,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -512,8 +548,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
value: z.enum(['lead', 'user', 'clear']),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, value }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, value }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
||||
|
|
@ -522,7 +559,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -534,10 +572,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
targetId: z.string().min(1),
|
||||
relationship: relationshipTypeSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.linkTask(
|
||||
taskId,
|
||||
targetId,
|
||||
relationship
|
||||
) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -549,12 +597,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
targetId: z.string().min(1),
|
||||
relationship: relationshipTypeSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship) as Record<string, unknown>)
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.unlinkTask(
|
||||
taskId,
|
||||
targetId,
|
||||
relationship
|
||||
) as Record<string, unknown>
|
||||
)
|
||||
),
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -566,7 +622,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
memberName: z.string().min(1),
|
||||
runtimeProvider: z.enum(['native', 'opencode']).optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => ({
|
||||
execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
|
|
@ -575,7 +633,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -585,13 +644,16 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName }) => ({
|
||||
execute: async ({ teamName, claudeDir, memberName }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
40
mcp-server/src/utils/teamConfig.ts
Normal file
40
mcp-server/src/utils/teamConfig.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getController } from '../controller';
|
||||
|
||||
function resolveConfigPath(teamName: string, claudeDir?: string): string {
|
||||
const controller = getController(teamName, claudeDir) as {
|
||||
context?: { paths?: { teamDir?: string } };
|
||||
};
|
||||
const teamDir = controller.context?.paths?.teamDir;
|
||||
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
return path.join(teamDir, 'config.json');
|
||||
}
|
||||
|
||||
export function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
|
||||
const configPath = resolveConfigPath(teamName, claudeDir);
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -487,6 +487,52 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('fails closed for primary queue and inventory tools when team config is missing over stdio', async () => {
|
||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||
const expected =
|
||||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.';
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
const leadBriefing = (await client.callTool(
|
||||
'lead_briefing',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
},
|
||||
40
|
||||
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
|
||||
expect(leadBriefing.result?.isError).toBe(true);
|
||||
expect(leadBriefing.result?.content?.[0]?.text).toContain(expected);
|
||||
|
||||
const taskBriefing = (await client.callTool(
|
||||
'task_briefing',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
memberName: 'alice',
|
||||
},
|
||||
41
|
||||
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
|
||||
expect(taskBriefing.result?.isError).toBe(true);
|
||||
expect(taskBriefing.result?.content?.[0]?.text).toContain(expected);
|
||||
|
||||
const taskList = (await client.callTool(
|
||||
'task_list',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
},
|
||||
42
|
||||
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
|
||||
expect(taskList.result?.isError).toBe(true);
|
||||
expect(taskList.result?.content?.[0]?.text).toContain(expected);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('caps high-volume task_list inventory over stdio and keeps rows compact', async () => {
|
||||
await writeTeamConfig(claudeDir, 'bulk-inventory-team');
|
||||
await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225);
|
||||
|
|
@ -1909,6 +1955,38 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
const startDeletedErrorText =
|
||||
startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? '');
|
||||
expect(startDeletedErrorText).toContain('use task_restore before starting work');
|
||||
|
||||
const restoreResult = await client.callTool(
|
||||
'task_restore',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
actor: 'team-lead',
|
||||
},
|
||||
110
|
||||
);
|
||||
const restored = parseJsonToolResult((restoreResult as { result: unknown }).result);
|
||||
expect(restored.status).toBe('pending');
|
||||
expect(restored.reviewState).toBe('none');
|
||||
|
||||
const restoreAgainResult = await client.callTool(
|
||||
'task_restore',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
actor: 'team-lead',
|
||||
},
|
||||
111
|
||||
);
|
||||
const restoreAgainResponse = restoreAgainResult as {
|
||||
error?: { message?: string };
|
||||
result?: { content?: Array<{ text?: string }> };
|
||||
};
|
||||
const restoreAgainErrorText =
|
||||
restoreAgainResponse.error?.message ?? (restoreAgainResponse.result?.content?.[0]?.text ?? '');
|
||||
expect(restoreAgainErrorText).toContain('task_restore only restores deleted tasks');
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
res.end(JSON.stringify(result.body));
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
||||
res.end(
|
||||
JSON.stringify({ error: error instanceof Error ? error.message : String(error) })
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -126,6 +128,10 @@ describe('agent-teams-mcp tools', () => {
|
|||
});
|
||||
|
||||
it('launches and stops teams through the runtime MCP tools', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
|
|
@ -172,6 +178,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
try {
|
||||
const launched = parseJsonToolResult(
|
||||
await getTool('team_launch').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
cwd: '/tmp/project',
|
||||
controlUrl: server.baseUrl,
|
||||
|
|
@ -183,6 +190,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
|
||||
const stopped = parseJsonToolResult(
|
||||
await getTool('team_stop').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
})
|
||||
|
|
@ -217,6 +225,13 @@ describe('agent-teams-mcp tools', () => {
|
|||
});
|
||||
|
||||
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
],
|
||||
});
|
||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
|
|
@ -225,6 +240,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
|
||||
try {
|
||||
await getTool('runtime_bootstrap_checkin').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
runId: 'run-oc',
|
||||
|
|
@ -232,6 +248,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
runtimeSessionId: 'ses-1',
|
||||
});
|
||||
await getTool('runtime_deliver_message').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
idempotencyKey: 'idem-1',
|
||||
|
|
@ -242,6 +259,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
text: 'hello',
|
||||
});
|
||||
await getTool('runtime_task_event').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
idempotencyKey: 'idem-task-1',
|
||||
|
|
@ -252,6 +270,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
event: 'started',
|
||||
});
|
||||
await getTool('runtime_heartbeat').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
runId: 'run-oc',
|
||||
|
|
@ -281,6 +300,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
|
||||
it('discovers the control endpoint from the published state file', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
const statePath = path.join(claudeDir, 'team-control-api.json');
|
||||
|
||||
const server = await startControlServer(async ({ method, url }) => {
|
||||
|
|
@ -665,12 +687,16 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(ownerInbox[0].text).toContain('task_start');
|
||||
expect(ownerInbox[0].text).toContain('task_add_comment');
|
||||
expect(ownerInbox[0].text).toContain('Read the plan before starting.');
|
||||
expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.');
|
||||
expect(ownerInbox[0].text).toContain(
|
||||
'If you are idle and this task is ready to start, start it now.'
|
||||
);
|
||||
expect(ownerInbox[0].text).toContain(
|
||||
'If you are busy, blocked, or still need more context, immediately add a short task comment'
|
||||
);
|
||||
expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`);
|
||||
expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.');
|
||||
expect(ownerInbox[3].text).toContain(
|
||||
'If you are idle and this task is ready to start, start it now.'
|
||||
);
|
||||
expect(ownerInbox[3].text).toContain('task_add_comment');
|
||||
|
||||
const briefing = (await getTool('task_briefing').execute({
|
||||
|
|
@ -712,14 +738,22 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(memberBriefingText).toContain(
|
||||
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
|
||||
);
|
||||
expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
|
||||
expect(memberBriefingText).toContain(
|
||||
'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):'
|
||||
);
|
||||
expect(memberBriefingText).toContain('Task briefing for alice:');
|
||||
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
|
||||
|
||||
fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'carol.json'), '[]');
|
||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]');
|
||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]');
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'),
|
||||
'[]'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'),
|
||||
'[]'
|
||||
);
|
||||
|
||||
const inboxResolvedBriefing = (await getTool('member_briefing').execute({
|
||||
claudeDir,
|
||||
|
|
@ -727,7 +761,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
memberName: 'carol',
|
||||
})) as { content: Array<{ text: string }> };
|
||||
const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? '';
|
||||
expect(inboxResolvedBriefingText).toContain('Member briefing for carol on team "gamma" (gamma).');
|
||||
expect(inboxResolvedBriefingText).toContain(
|
||||
'Member briefing for carol on team "gamma" (gamma).'
|
||||
);
|
||||
expect(inboxResolvedBriefingText).toContain('Role: team member.');
|
||||
|
||||
await expect(
|
||||
|
|
@ -914,9 +950,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
teamName,
|
||||
})
|
||||
);
|
||||
expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe(
|
||||
'needsFix'
|
||||
);
|
||||
expect(
|
||||
listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState
|
||||
).toBe('needsFix');
|
||||
|
||||
const kanbanCleared = parseJsonToolResult(
|
||||
await getTool('kanban_clear').execute({
|
||||
|
|
@ -1061,6 +1097,26 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
expect(kanbanState.tasks[reviewTask.id]).toBeUndefined();
|
||||
expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id);
|
||||
|
||||
const restored = parseJsonToolResult(
|
||||
await getTool('task_restore').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: reviewTask.id,
|
||||
actor: 'lead',
|
||||
})
|
||||
);
|
||||
expect(restored.status).toBe('pending');
|
||||
expect(restored.reviewState).toBe('none');
|
||||
|
||||
await expect(
|
||||
getTool('task_restore').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: reviewTask.id,
|
||||
actor: 'lead',
|
||||
})
|
||||
).rejects.toThrow('task_restore only restores deleted tasks');
|
||||
});
|
||||
|
||||
it('only notifies the owner on review_approve when notifyOwner is explicit', async () => {
|
||||
|
|
@ -1222,6 +1278,41 @@ describe('agent-teams-mcp tools', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('rejects non-configured teams before MCP side-effect writes', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'real-team', {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getTool('message_send').execute({
|
||||
claudeDir,
|
||||
teamName: 'typo-team',
|
||||
to: 'alice',
|
||||
text: 'Should not create inbox',
|
||||
})
|
||||
).rejects.toThrow('Unknown team "typo-team"');
|
||||
await expect(
|
||||
getTool('process_register').execute({
|
||||
claudeDir,
|
||||
teamName: 'typo-team',
|
||||
pid: process.pid,
|
||||
label: 'watcher',
|
||||
})
|
||||
).rejects.toThrow('Unknown team "typo-team"');
|
||||
await expect(
|
||||
getTool('cross_team_send').execute({
|
||||
claudeDir,
|
||||
teamName: 'typo-team',
|
||||
toTeam: 'real-team',
|
||||
text: 'Should not deliver',
|
||||
})
|
||||
).rejects.toThrow('Unknown team "typo-team"');
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'typo-team'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes zod schemas that reject obviously invalid payloads', () => {
|
||||
expect(
|
||||
getTool('task_create').parameters?.safeParse({
|
||||
|
|
@ -1370,9 +1461,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(completed.comments).toBeUndefined();
|
||||
|
||||
// task_list: explicit inventory shape only
|
||||
const listed = parseJsonToolResult(
|
||||
await getTool('task_list').execute({ claudeDir, teamName })
|
||||
);
|
||||
const listed = parseJsonToolResult(await getTool('task_list').execute({ claudeDir, teamName }));
|
||||
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
|
||||
expect(listedTask).toBeDefined();
|
||||
expect(listedTask).toEqual({
|
||||
|
|
@ -1412,9 +1501,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json');
|
||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
const existing = fs.existsSync(sentPath)
|
||||
? JSON.parse(fs.readFileSync(sentPath, 'utf8'))
|
||||
: [];
|
||||
const existing = fs.existsSync(sentPath) ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) : [];
|
||||
existing.push(message);
|
||||
fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2));
|
||||
}
|
||||
|
|
@ -1760,9 +1847,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
text: 'Roundtrip test message',
|
||||
timestamp: '2026-03-15T16:00:00.000Z',
|
||||
source: 'user_sent',
|
||||
attachments: [
|
||||
{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 },
|
||||
],
|
||||
attachments: [{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }],
|
||||
});
|
||||
|
||||
const created = parseJsonToolResult(
|
||||
|
|
@ -1880,4 +1965,20 @@ describe('agent-teams-mcp tools', () => {
|
|||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for primary queue and inventory tools when team config does not exist', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const params = { claudeDir, teamName: 'team-lead' };
|
||||
const expected =
|
||||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.';
|
||||
|
||||
await expect(getTool('lead_briefing').execute(params)).rejects.toThrow(expected);
|
||||
await expect(
|
||||
getTool('task_briefing').execute({
|
||||
...params,
|
||||
memberName: 'alice',
|
||||
})
|
||||
).rejects.toThrow(expected);
|
||||
await expect(getTool('task_list').execute(params)).rejects.toThrow(expected);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,7 +21,6 @@
|
|||
"dev": "node ./scripts/dev-with-runtime.mjs",
|
||||
"dev:web": "node ./scripts/dev-web.mjs",
|
||||
"dev:kill": "node bin/kill-dev.js",
|
||||
"opencode:prove-production": "node ./scripts/prove-opencode-production.mjs",
|
||||
"opencode:prove-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
|
||||
"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",
|
||||
|
|
|
|||
|
|
@ -279,7 +279,13 @@ function drawLaunchStage(
|
|||
for (let index = 0; index < 3; index += 1) {
|
||||
const angle = time * 1.2 + (Math.PI * 2 * index) / 3;
|
||||
ctx.beginPath();
|
||||
ctx.arc(x + Math.cos(angle) * dotOrbit, y + Math.sin(angle) * dotOrbit, 1.7, 0, Math.PI * 2);
|
||||
ctx.arc(
|
||||
x + Math.cos(angle) * dotOrbit,
|
||||
y + Math.sin(angle) * dotOrbit,
|
||||
1.7,
|
||||
0,
|
||||
Math.PI * 2
|
||||
);
|
||||
ctx.fillStyle = hexWithAlpha('#e4e4e7', 0.72);
|
||||
ctx.fill();
|
||||
}
|
||||
|
|
@ -736,6 +742,13 @@ function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): stri
|
|||
return hexWithAlpha('#f59e0b', 0.92);
|
||||
case 'runtime_pending':
|
||||
return hexWithAlpha('#67e8f9', 0.9);
|
||||
case 'shell_only':
|
||||
case 'runtime_candidate':
|
||||
return hexWithAlpha('#f97316', 0.9);
|
||||
case 'registered_only':
|
||||
return hexWithAlpha('#a1a1aa', 0.82);
|
||||
case 'stale_runtime':
|
||||
return hexWithAlpha('#ef4444', 0.82);
|
||||
case 'settling':
|
||||
return hexWithAlpha('#22c55e', 0.9);
|
||||
case 'error':
|
||||
|
|
|
|||
|
|
@ -22,8 +22,13 @@ export type GraphLaunchVisualState =
|
|||
| 'spawning'
|
||||
| 'permission_pending'
|
||||
| 'runtime_pending'
|
||||
| 'shell_only'
|
||||
| 'runtime_candidate'
|
||||
| 'registered_only'
|
||||
| 'stale_runtime'
|
||||
| 'settling'
|
||||
| 'error';
|
||||
| 'error'
|
||||
| 'skipped';
|
||||
|
||||
// ─── Edge & Particle Types ───────────────────────────────────────────────────
|
||||
|
||||
|
|
@ -82,7 +87,7 @@ export interface GraphNode {
|
|||
/** Avatar image URL (e.g., robohash) */
|
||||
avatarUrl?: string;
|
||||
/** Spawn lifecycle status */
|
||||
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error';
|
||||
spawnStatus?: 'offline' | 'waiting' | 'spawning' | 'online' | 'error' | 'skipped';
|
||||
/** Shared launch-stage visual derived by the host app */
|
||||
launchVisualState?: GraphLaunchVisualState;
|
||||
/** Shared launch-stage text shown beside the node during launch only */
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ const runtimeCacheRoot = process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT?.trim()
|
|||
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
|
||||
: defaultRuntimeCacheRoot;
|
||||
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
|
||||
const runtimeDisplayName = 'teams orchestrator';
|
||||
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
|
||||
|
||||
function shouldUseWindowsShell(cmd) {
|
||||
|
|
@ -108,7 +109,8 @@ function getPlatformAssetKey() {
|
|||
}
|
||||
|
||||
function getReleaseAssetUrl(runtimeLock, asset) {
|
||||
const releaseTag = typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
|
||||
const releaseTag =
|
||||
typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
|
||||
? runtimeLock.releaseTag.trim()
|
||||
: runtimeLock.sourceRef;
|
||||
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`;
|
||||
|
|
@ -152,9 +154,7 @@ function truncateMiddle(value, maxLength) {
|
|||
|
||||
function buildProgressBar(progressRatio, width) {
|
||||
const safeWidth = Math.max(10, width);
|
||||
const clampedRatio = Number.isFinite(progressRatio)
|
||||
? Math.min(1, Math.max(0, progressRatio))
|
||||
: 0;
|
||||
const clampedRatio = Number.isFinite(progressRatio) ? Math.min(1, Math.max(0, progressRatio)) : 0;
|
||||
const filledWidth = Math.round(safeWidth * clampedRatio);
|
||||
return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`;
|
||||
}
|
||||
|
|
@ -164,7 +164,8 @@ function supportsProgressRedraw() {
|
|||
}
|
||||
|
||||
function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) {
|
||||
const columns = process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
|
||||
const columns =
|
||||
process.stdout.columns && process.stdout.columns > 0 ? process.stdout.columns : 100;
|
||||
const ratio = hasTotal ? writtenBytes / totalBytes : 0;
|
||||
const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : '';
|
||||
const bytesText = hasTotal
|
||||
|
|
@ -196,6 +197,16 @@ function readBinaryVersion(binaryPath) {
|
|||
return runAndCapture(binaryPath, ['--version']);
|
||||
}
|
||||
|
||||
function formatRuntimeVersionForDisplay(versionText) {
|
||||
const trimmed = versionText.trim();
|
||||
if (!trimmed) {
|
||||
return runtimeDisplayName;
|
||||
}
|
||||
|
||||
const versionOnly = trimmed.replace(/\s*\([^)]*\)\s*$/, '');
|
||||
return `${versionOnly} (${runtimeDisplayName})`;
|
||||
}
|
||||
|
||||
function isExecutable(filePath) {
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return false;
|
||||
|
|
@ -305,7 +316,10 @@ async function downloadWithProgress(url, destinationPath) {
|
|||
readline.clearLine(process.stdout, 0);
|
||||
readline.cursorTo(process.stdout, 0);
|
||||
process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`);
|
||||
} else if ((hasTotal && lastLoggedPercent < 100) || (!hasTotal && writtenBytes !== lastLoggedBytes)) {
|
||||
} else if (
|
||||
(hasTotal && lastLoggedPercent < 100) ||
|
||||
(!hasTotal && writtenBytes !== lastLoggedBytes)
|
||||
) {
|
||||
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
|
||||
}
|
||||
}
|
||||
|
|
@ -511,7 +525,9 @@ async function main() {
|
|||
if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) {
|
||||
process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`);
|
||||
}
|
||||
process.stdout.write(`Runtime version: ${resolvedRuntime.versionText}\n`);
|
||||
process.stdout.write(
|
||||
`Runtime version: ${formatRuntimeVersionForDisplay(resolvedRuntime.versionText)}\n`
|
||||
);
|
||||
|
||||
const uiEnv = {
|
||||
...process.env,
|
||||
|
|
|
|||
|
|
@ -1,72 +0,0 @@
|
|||
#!/usr/bin/env node
|
||||
|
||||
import { spawnSync } from 'node:child_process';
|
||||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
import process from 'node:process';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const repoRoot = path.resolve(scriptDir, '..');
|
||||
const defaultEvidencePath = path.join(
|
||||
resolveAppDataDir(),
|
||||
'Agent Teams UI',
|
||||
'opencode-bridge',
|
||||
'production-e2e-evidence.json'
|
||||
);
|
||||
const orchestratorRoot = process.env.CLAUDE_DEV_RUNTIME_ROOT?.trim();
|
||||
const siblingOrchestrator = path.resolve(repoRoot, '..', 'agent_teams_orchestrator');
|
||||
|
||||
const env = {
|
||||
...process.env,
|
||||
OPENCODE_E2E: '1',
|
||||
OPENCODE_E2E_PROJECT_PATH: process.env.OPENCODE_E2E_PROJECT_PATH?.trim() || repoRoot,
|
||||
OPENCODE_E2E_MODEL: process.env.OPENCODE_E2E_MODEL?.trim() || 'opencode/big-pickle',
|
||||
OPENCODE_E2E_WRITE_APP_EVIDENCE: '1',
|
||||
OPENCODE_E2E_WRITE_EVIDENCE_PATH:
|
||||
process.env.OPENCODE_E2E_WRITE_EVIDENCE_PATH?.trim() ||
|
||||
process.env.CLAUDE_TEAM_OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH?.trim() ||
|
||||
defaultEvidencePath,
|
||||
OPENCODE_DISABLE_AUTOUPDATE: process.env.OPENCODE_DISABLE_AUTOUPDATE ?? '1',
|
||||
};
|
||||
|
||||
if (!env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim()) {
|
||||
const runtimeRoot = orchestratorRoot ? path.resolve(orchestratorRoot) : siblingOrchestrator;
|
||||
env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH = path.join(runtimeRoot, 'cli');
|
||||
}
|
||||
|
||||
console.log('Running OpenCode production proof');
|
||||
console.log(`Model: ${env.OPENCODE_E2E_MODEL}`);
|
||||
console.log(`Project: ${env.OPENCODE_E2E_PROJECT_PATH}`);
|
||||
console.log(`Evidence: ${env.OPENCODE_E2E_WRITE_EVIDENCE_PATH}`);
|
||||
console.log(`Orchestrator CLI: ${env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH}`);
|
||||
|
||||
const result = spawnSync(
|
||||
'pnpm',
|
||||
['exec', 'vitest', 'run', 'test/main/services/team/OpenCodeProductionGate.live.test.ts'],
|
||||
{
|
||||
cwd: repoRoot,
|
||||
env,
|
||||
stdio: 'inherit',
|
||||
shell: process.platform === 'win32',
|
||||
}
|
||||
);
|
||||
|
||||
if (result.error) {
|
||||
console.error(`Failed to run OpenCode production proof: ${result.error.message}`);
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
process.exit(result.status ?? 1);
|
||||
|
||||
function resolveAppDataDir() {
|
||||
if (process.platform === 'darwin') {
|
||||
return path.join(os.homedir(), 'Library', 'Application Support');
|
||||
}
|
||||
|
||||
if (process.platform === 'win32') {
|
||||
return process.env.APPDATA || path.join(os.homedir(), 'AppData', 'Roaming');
|
||||
}
|
||||
|
||||
return process.env.XDG_CONFIG_HOME || path.join(os.homedir(), '.config');
|
||||
}
|
||||
|
|
@ -13,7 +13,7 @@ import type {
|
|||
} from '../ports/RecentProjectsSourcePort';
|
||||
|
||||
const DEFAULT_CACHE_TTL_MS = 10_000;
|
||||
const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500;
|
||||
const DEFAULT_DEGRADED_CACHE_TTL_MS = 30_000;
|
||||
|
||||
interface SourceLoadResult {
|
||||
candidates: RecentProjectCandidate[];
|
||||
|
|
@ -99,9 +99,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
|||
}
|
||||
|
||||
const viewModel = this.deps.output.present(response);
|
||||
const cacheTtlMs = hasDegradedSources
|
||||
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
|
||||
: this.#cacheTtlMs;
|
||||
const cacheTtlMs = hasDegradedSources ? this.#degradedCacheTtlMs : this.#cacheTtlMs;
|
||||
|
||||
await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
|
||||
this.deps.logger.info('recent-projects loaded', {
|
||||
|
|
|
|||
|
|
@ -16,16 +16,26 @@ import type {
|
|||
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
|
||||
import type { ServiceContext } from '@main/services';
|
||||
|
||||
const CODEX_THREAD_LIMIT = 40;
|
||||
const CODEX_THREAD_LIMIT = 20;
|
||||
const CODEX_INITIALIZE_TIMEOUT_MS = 6_000;
|
||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000;
|
||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000;
|
||||
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
||||
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
|
||||
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||
CODEX_INITIALIZE_TIMEOUT_MS +
|
||||
CODEX_ARCHIVED_FETCH_TIMEOUT_MS +
|
||||
CODEX_LIVE_FETCH_TIMEOUT_MS +
|
||||
CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
|
||||
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
|
||||
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||
const CODEX_STALE_CANDIDATES_TTL_MS = 5 * 60_000;
|
||||
const CODEX_FULL_FAILURE_COOLDOWN_MS = 30_000;
|
||||
|
||||
interface StaleCodexCandidatesSnapshot {
|
||||
candidates: RecentProjectCandidate[];
|
||||
capturedAt: number;
|
||||
}
|
||||
|
||||
function isInteractiveSource(source: unknown): boolean {
|
||||
return source === 'vscode' || source === 'cli';
|
||||
|
|
@ -42,9 +52,24 @@ function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean {
|
|||
return Boolean(result.live.error || result.archived.error);
|
||||
}
|
||||
|
||||
function getFullFailureReason(result: CodexRecentThreadsResult): string | null {
|
||||
if (!result.live.error || !result.archived.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.live.error === result.archived.error) {
|
||||
return result.live.error;
|
||||
}
|
||||
|
||||
return `live: ${result.live.error}; archived: ${result.archived.error}`;
|
||||
}
|
||||
|
||||
export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
|
||||
readonly sourceId = 'codex';
|
||||
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
|
||||
#staleCandidatesSnapshot: StaleCodexCandidatesSnapshot | null = null;
|
||||
#fullFailureCooldownUntil = 0;
|
||||
#fullFailureCooldownReason: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
|
|
@ -77,8 +102,19 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
};
|
||||
}
|
||||
|
||||
const cooldown = this.#getActiveCooldown();
|
||||
if (cooldown) {
|
||||
this.deps.logger.info('codex recent-projects source cooldown active', cooldown);
|
||||
return {
|
||||
candidates: this.#getFreshStaleCandidates() ?? [],
|
||||
degraded: true,
|
||||
};
|
||||
}
|
||||
|
||||
const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
|
||||
const degraded = isDegradedThreadResult(threadSegments);
|
||||
const fullFailureReason = getFullFailureReason(threadSegments);
|
||||
this.#updateFullFailureCooldown(fullFailureReason);
|
||||
this.#logSegmentFailure(threadSegments, 'live');
|
||||
this.#logSegmentFailure(threadSegments, 'archived');
|
||||
const liveThreads = threadSegments.live.threads;
|
||||
|
|
@ -92,6 +128,25 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
|
||||
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
|
||||
|
||||
if (!degraded) {
|
||||
this.#rememberHealthyCandidates(candidates);
|
||||
}
|
||||
|
||||
if (degraded && candidates.length === 0) {
|
||||
const staleCandidates = this.#getFreshStaleCandidates();
|
||||
if (staleCandidates) {
|
||||
this.deps.logger.info('codex recent-projects served stale candidates', {
|
||||
count: staleCandidates.length,
|
||||
reason: fullFailureReason ?? 'degraded-empty-result',
|
||||
});
|
||||
|
||||
return {
|
||||
candidates: staleCandidates,
|
||||
degraded: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.deps.logger.info('codex recent-projects source loaded', {
|
||||
count: candidates.length,
|
||||
degraded,
|
||||
|
|
@ -103,6 +158,53 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
};
|
||||
}
|
||||
|
||||
#getActiveCooldown(): { retryAfterMs: number; reason: string | null } | null {
|
||||
const retryAfterMs = this.#fullFailureCooldownUntil - Date.now();
|
||||
if (retryAfterMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
retryAfterMs,
|
||||
reason: this.#fullFailureCooldownReason,
|
||||
};
|
||||
}
|
||||
|
||||
#updateFullFailureCooldown(reason: string | null): void {
|
||||
if (!reason) {
|
||||
this.#fullFailureCooldownUntil = 0;
|
||||
this.#fullFailureCooldownReason = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.#fullFailureCooldownUntil = Date.now() + CODEX_FULL_FAILURE_COOLDOWN_MS;
|
||||
this.#fullFailureCooldownReason = reason;
|
||||
}
|
||||
|
||||
#rememberHealthyCandidates(candidates: RecentProjectCandidate[]): void {
|
||||
this.#staleCandidatesSnapshot =
|
||||
candidates.length > 0
|
||||
? {
|
||||
candidates,
|
||||
capturedAt: Date.now(),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
#getFreshStaleCandidates(): RecentProjectCandidate[] | null {
|
||||
const snapshot = this.#staleCandidatesSnapshot;
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - snapshot.capturedAt > CODEX_STALE_CANDIDATES_TTL_MS) {
|
||||
this.#staleCandidatesSnapshot = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
return [...snapshot.candidates];
|
||||
}
|
||||
|
||||
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
||||
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
|
||||
limit: CODEX_THREAD_LIMIT,
|
||||
|
|
|
|||
|
|
@ -74,10 +74,7 @@ export class CodexAppServerClient {
|
|||
}
|
||||
): Promise<CodexThreadSegmentResult> {
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const initializeTimeoutMs = Math.max(
|
||||
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
||||
requestTimeoutMs
|
||||
);
|
||||
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
||||
const totalTimeoutMs = Math.max(
|
||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||
initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
|
|
@ -122,13 +119,13 @@ export class CodexAppServerClient {
|
|||
const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
||||
const initializeTimeoutMs = Math.max(
|
||||
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
||||
sessionRequestTimeoutMs
|
||||
);
|
||||
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
||||
const totalTimeoutMs = Math.max(
|
||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||
initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
initializeTimeoutMs +
|
||||
liveRequestTimeoutMs +
|
||||
archivedRequestTimeoutMs +
|
||||
MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
);
|
||||
|
||||
return this.#withThreadListSession(
|
||||
|
|
@ -140,50 +137,55 @@ export class CodexAppServerClient {
|
|||
label: 'codex app-server thread/list',
|
||||
},
|
||||
async (session) => {
|
||||
const [live, archived] = await Promise.allSettled([
|
||||
session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
const live = await this.#requestThreadListSegment(session, {
|
||||
archived: false,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
liveRequestTimeoutMs
|
||||
),
|
||||
session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
timeoutMs: liveRequestTimeoutMs,
|
||||
});
|
||||
const archived = await this.#requestThreadListSegment(session, {
|
||||
archived: true,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
archivedRequestTimeoutMs
|
||||
),
|
||||
]);
|
||||
timeoutMs: archivedRequestTimeoutMs,
|
||||
});
|
||||
|
||||
return {
|
||||
live:
|
||||
live.status === 'fulfilled'
|
||||
? { threads: live.value.data ?? [] }
|
||||
: {
|
||||
threads: [],
|
||||
error: live.reason instanceof Error ? live.reason.message : String(live.reason),
|
||||
},
|
||||
archived:
|
||||
archived.status === 'fulfilled'
|
||||
? { threads: archived.value.data ?? [] }
|
||||
: {
|
||||
threads: [],
|
||||
error:
|
||||
archived.reason instanceof Error
|
||||
? archived.reason.message
|
||||
: String(archived.reason),
|
||||
},
|
||||
live,
|
||||
archived,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async #requestThreadListSegment(
|
||||
session: JsonRpcSession,
|
||||
options: {
|
||||
archived: boolean;
|
||||
limit: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<CodexThreadSegmentResult> {
|
||||
try {
|
||||
const response = await session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: options.archived,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
options.timeoutMs
|
||||
);
|
||||
|
||||
return {
|
||||
threads: response.data ?? [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
threads: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async #withThreadListSession<T>(
|
||||
options: ThreadListSessionOptions,
|
||||
handler: (session: JsonRpcSession) => Promise<T>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import type { TeamSummary } from '@shared/types';
|
|||
|
||||
const INITIAL_RECENT_PROJECTS = 11;
|
||||
const LOAD_MORE_STEP = 8;
|
||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500;
|
||||
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000;
|
||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 30_000;
|
||||
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 120_000;
|
||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3;
|
||||
|
||||
function matchesSearch(project: DashboardRecentProject, query: string): boolean {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
} from '@features/recent-projects/contracts';
|
||||
|
||||
const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
|
||||
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 1_500;
|
||||
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 30_000;
|
||||
|
||||
let cachedPayload: DashboardRecentProjectsPayloadLike = null;
|
||||
let cachedAt = 0;
|
||||
|
|
|
|||
|
|
@ -76,6 +76,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
shellOnlyPendingCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
runtimeCandidatePendingCount: 0,
|
||||
noRuntimePendingCount: 0,
|
||||
permissionPendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
||||
});
|
||||
|
|
@ -130,6 +135,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
runtimePid: 333,
|
||||
runtimeSessionId: 'session-bob',
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
pidSource: 'runtime_bootstrap',
|
||||
runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
diagnostics: ['spawn accepted', 'late heartbeat received'],
|
||||
},
|
||||
},
|
||||
|
|
@ -145,12 +155,22 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
runtimePid: 333,
|
||||
runtimeSessionId: 'session-bob',
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
pidSource: 'runtime_bootstrap',
|
||||
runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
});
|
||||
expect(snapshot.summary).toEqual({
|
||||
confirmedCount: 2,
|
||||
pendingCount: 0,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
shellOnlyPendingCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
runtimeCandidatePendingCount: 0,
|
||||
noRuntimePendingCount: 0,
|
||||
permissionPendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('clean_success');
|
||||
});
|
||||
|
|
@ -229,6 +249,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
pendingCount: 0,
|
||||
failedCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
shellOnlyPendingCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
runtimeCandidatePendingCount: 0,
|
||||
noRuntimePendingCount: 0,
|
||||
permissionPendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('partial_failure');
|
||||
});
|
||||
|
|
@ -279,9 +304,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
evidence: {
|
||||
launchState: 'runtime_pending_permission',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
livenessKind: 'permission_blocked',
|
||||
runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
||||
},
|
||||
},
|
||||
|
|
@ -292,9 +320,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
launchState: 'runtime_pending_permission',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
agentToolAccepted: true,
|
||||
bootstrapConfirmed: false,
|
||||
livenessKind: 'permission_blocked',
|
||||
runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
||||
hardFailure: false,
|
||||
});
|
||||
|
|
@ -303,7 +334,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
confirmedCount: 1,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
shellOnlyPendingCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
runtimeCandidatePendingCount: 0,
|
||||
noRuntimePendingCount: 0,
|
||||
permissionPendingCount: 1,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import type {
|
|||
PersistedTeamLaunchPhase,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
ProviderModelLaunchIdentity,
|
||||
TeamAgentRuntimeDiagnosticSeverity,
|
||||
TeamAgentRuntimeLivenessKind,
|
||||
TeamAgentRuntimePidSource,
|
||||
TeamFastMode,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
|
|
@ -38,6 +41,12 @@ export interface MixedSecondaryLaneMemberStateInput {
|
|||
hardFailureReason?: string;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
runtimePid?: number;
|
||||
runtimeSessionId?: string;
|
||||
sessionId?: string;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
diagnostics?: string[];
|
||||
} | null;
|
||||
pendingReason?: string;
|
||||
|
|
@ -65,6 +74,19 @@ function deriveMemberLaunchState(params: {
|
|||
return 'starting';
|
||||
}
|
||||
|
||||
function preservesStrongRuntimeAlive(value: {
|
||||
runtimeAlive?: boolean;
|
||||
bootstrapConfirmed?: boolean;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
}): boolean {
|
||||
return (
|
||||
value.runtimeAlive === true &&
|
||||
(value.bootstrapConfirmed === true ||
|
||||
value.livenessKind === 'confirmed_bootstrap' ||
|
||||
value.livenessKind === 'runtime_process')
|
||||
);
|
||||
}
|
||||
|
||||
function buildDiagnostics(
|
||||
member: Pick<
|
||||
PersistedTeamLaunchMemberState,
|
||||
|
|
@ -93,14 +115,17 @@ function buildDiagnostics(
|
|||
}
|
||||
|
||||
function createSourcesFromStatus(
|
||||
status: Pick<MemberSpawnStatusEntry, 'livenessSource' | 'runtimeAlive'>
|
||||
status: Pick<
|
||||
MemberSpawnStatusEntry,
|
||||
'livenessSource' | 'runtimeAlive' | 'bootstrapConfirmed' | 'livenessKind'
|
||||
>
|
||||
): PersistedTeamLaunchMemberSources | undefined {
|
||||
const sources: PersistedTeamLaunchMemberSources = {};
|
||||
if (status.livenessSource === 'heartbeat') {
|
||||
sources.nativeHeartbeat = true;
|
||||
sources.inboxHeartbeat = true;
|
||||
}
|
||||
if (status.livenessSource === 'process' || status.runtimeAlive) {
|
||||
if (status.livenessSource === 'process' && preservesStrongRuntimeAlive(status)) {
|
||||
sources.processAlive = true;
|
||||
}
|
||||
return Object.values(sources).some(Boolean) ? sources : undefined;
|
||||
|
|
@ -119,6 +144,7 @@ function createPrimaryLaneMemberState(params: {
|
|||
const providerId =
|
||||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||
const runtime = params.status;
|
||||
const strongRuntimeAlive = preservesStrongRuntimeAlive(runtime ?? {});
|
||||
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
|
||||
const base: PersistedTeamLaunchMemberState = {
|
||||
name: params.member.name.trim(),
|
||||
|
|
@ -151,21 +177,25 @@ function createPrimaryLaneMemberState(params: {
|
|||
deriveMemberLaunchState({
|
||||
hardFailure: runtime?.hardFailure,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed,
|
||||
runtimeAlive: runtime?.runtimeAlive,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
agentToolAccepted: runtime?.agentToolAccepted,
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
|
||||
}),
|
||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||
runtimeAlive: runtime?.runtimeAlive === true,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
livenessKind: runtime?.livenessKind,
|
||||
runtimeDiagnostic: runtime?.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
|
||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? params.updatedAt : undefined,
|
||||
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
|
||||
lastRuntimeAliveAt: preservesStrongRuntimeAlive(runtime ?? {}) ? params.updatedAt : undefined,
|
||||
lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt,
|
||||
sources,
|
||||
diagnostics: undefined,
|
||||
|
|
@ -180,13 +210,14 @@ function createSecondaryLaneMemberState(
|
|||
const providerId =
|
||||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||
const evidence = params.evidence;
|
||||
const strongRuntimeAlive = preservesStrongRuntimeAlive(evidence ?? {});
|
||||
const hardFailureReason = evidence?.hardFailureReason;
|
||||
const launchState =
|
||||
evidence?.launchState ??
|
||||
deriveMemberLaunchState({
|
||||
hardFailure: evidence?.hardFailure,
|
||||
bootstrapConfirmed: evidence?.bootstrapConfirmed,
|
||||
runtimeAlive: evidence?.runtimeAlive,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
agentToolAccepted: evidence?.agentToolAccepted,
|
||||
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
|
||||
});
|
||||
|
|
@ -214,7 +245,7 @@ function createSecondaryLaneMemberState(
|
|||
laneOwnerProviderId: providerId,
|
||||
launchState,
|
||||
agentToolAccepted: evidence?.agentToolAccepted === true,
|
||||
runtimeAlive: evidence?.runtimeAlive === true,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
|
||||
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
|
||||
hardFailureReason,
|
||||
|
|
@ -227,15 +258,21 @@ function createSecondaryLaneMemberState(
|
|||
evidence.runtimePid > 0
|
||||
? Math.trunc(evidence.runtimePid)
|
||||
: undefined,
|
||||
runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId,
|
||||
livenessKind: evidence?.livenessKind,
|
||||
pidSource: evidence?.pidSource,
|
||||
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: evidence?.runtimeDiagnosticSeverity,
|
||||
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
||||
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
||||
lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined,
|
||||
runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||
lastRuntimeAliveAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||
lastEvaluatedAt: params.updatedAt,
|
||||
sources: evidence?.runtimeAlive
|
||||
sources: strongRuntimeAlive
|
||||
? {
|
||||
processAlive: true,
|
||||
nativeHeartbeat: evidence.bootstrapConfirmed === true || undefined,
|
||||
inboxHeartbeat: evidence.bootstrapConfirmed === true || undefined,
|
||||
nativeHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
|
||||
inboxHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
|
||||
}
|
||||
: undefined,
|
||||
diagnostics: evidence?.diagnostics?.length
|
||||
|
|
@ -256,6 +293,11 @@ function summarizeMembers(
|
|||
let pendingCount = 0;
|
||||
let failedCount = 0;
|
||||
let runtimeAlivePendingCount = 0;
|
||||
let shellOnlyPendingCount = 0;
|
||||
let runtimeProcessPendingCount = 0;
|
||||
let runtimeCandidatePendingCount = 0;
|
||||
let noRuntimePendingCount = 0;
|
||||
let permissionPendingCount = 0;
|
||||
|
||||
for (const memberName of expectedMembers) {
|
||||
const entry = members[memberName];
|
||||
|
|
@ -275,6 +317,22 @@ function summarizeMembers(
|
|||
if (entry.runtimeAlive) {
|
||||
runtimeAlivePendingCount += 1;
|
||||
}
|
||||
if (entry.launchState === 'runtime_pending_permission') {
|
||||
permissionPendingCount += 1;
|
||||
}
|
||||
if (entry.livenessKind === 'shell_only') {
|
||||
shellOnlyPendingCount += 1;
|
||||
} else if (entry.livenessKind === 'runtime_process') {
|
||||
runtimeProcessPendingCount += 1;
|
||||
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
||||
runtimeCandidatePendingCount += 1;
|
||||
} else if (
|
||||
entry.livenessKind === 'not_found' ||
|
||||
entry.livenessKind === 'stale_metadata' ||
|
||||
entry.livenessKind === 'registered_only'
|
||||
) {
|
||||
noRuntimePendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -282,6 +340,11 @@ function summarizeMembers(
|
|||
pendingCount,
|
||||
failedCount,
|
||||
runtimeAlivePendingCount,
|
||||
shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount,
|
||||
runtimeCandidatePendingCount,
|
||||
noRuntimePendingCount,
|
||||
permissionPendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,9 @@
|
|||
import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter';
|
||||
import { TmuxPlatformCommandExecutor } from '../infrastructure/runtime/TmuxPlatformCommandExecutor';
|
||||
import {
|
||||
type RuntimeProcessTableRow,
|
||||
type TmuxPaneRuntimeInfo,
|
||||
TmuxPlatformCommandExecutor,
|
||||
} from '../infrastructure/runtime/TmuxPlatformCommandExecutor';
|
||||
|
||||
const runtimeStatusSource = new TmuxStatusSourceAdapter();
|
||||
const runtimeCommandExecutor = new TmuxPlatformCommandExecutor();
|
||||
|
|
@ -24,6 +28,18 @@ export async function listTmuxPanePidsForCurrentPlatform(
|
|||
return runtimeCommandExecutor.listPanePids(paneIds);
|
||||
}
|
||||
|
||||
export async function listTmuxPaneRuntimeInfoForCurrentPlatform(
|
||||
paneIds: readonly string[]
|
||||
): Promise<Map<string, TmuxPaneRuntimeInfo>> {
|
||||
return runtimeCommandExecutor.listPaneRuntimeInfo(paneIds);
|
||||
}
|
||||
|
||||
export async function listRuntimeProcessesForCurrentTmuxPlatform(): Promise<
|
||||
RuntimeProcessTableRow[]
|
||||
> {
|
||||
return runtimeCommandExecutor.listRuntimeProcesses();
|
||||
}
|
||||
|
||||
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
|
||||
runtimeCommandExecutor.killPaneSync(paneId);
|
||||
invalidateTmuxRuntimeStatusCache();
|
||||
|
|
|
|||
|
|
@ -9,5 +9,12 @@ export {
|
|||
isTmuxRuntimeReadyForCurrentPlatform,
|
||||
killTmuxPaneForCurrentPlatform,
|
||||
killTmuxPaneForCurrentPlatformSync,
|
||||
listRuntimeProcessesForCurrentTmuxPlatform,
|
||||
listTmuxPanePidsForCurrentPlatform,
|
||||
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
||||
} from './composition/runtimeSupport';
|
||||
export type {
|
||||
RuntimeProcessTableRow,
|
||||
TmuxPaneRuntimeInfo,
|
||||
} from './infrastructure/runtime/TmuxPlatformCommandExecutor';
|
||||
export { parseRuntimeProcessTable } from './infrastructure/runtime/TmuxPlatformCommandExecutor';
|
||||
|
|
|
|||
|
|
@ -12,6 +12,43 @@ interface ExecResult {
|
|||
stderr: string;
|
||||
}
|
||||
|
||||
export interface TmuxPaneRuntimeInfo {
|
||||
paneId: string;
|
||||
panePid: number;
|
||||
currentCommand?: string;
|
||||
currentPath?: string;
|
||||
sessionName?: string;
|
||||
windowName?: string;
|
||||
}
|
||||
|
||||
export interface RuntimeProcessTableRow {
|
||||
pid: number;
|
||||
ppid: number;
|
||||
command: string;
|
||||
}
|
||||
|
||||
export function parseRuntimeProcessTable(output: string): RuntimeProcessTableRow[] {
|
||||
const rows: RuntimeProcessTableRow[] = [];
|
||||
for (const line of output.split('\n')) {
|
||||
const match = /^\s*(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
||||
if (!match) continue;
|
||||
|
||||
const pid = Number.parseInt(match[1], 10);
|
||||
const ppid = Number.parseInt(match[2], 10);
|
||||
const command = match[3]?.trim() ?? '';
|
||||
if (
|
||||
Number.isFinite(pid) &&
|
||||
pid > 0 &&
|
||||
Number.isFinite(ppid) &&
|
||||
ppid >= 0 &&
|
||||
command.length > 0
|
||||
) {
|
||||
rows.push({ pid, ppid, command });
|
||||
}
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
export class TmuxPlatformCommandExecutor {
|
||||
readonly #wslService: TmuxWslService;
|
||||
readonly #packageManagerResolver: TmuxPackageManagerResolver;
|
||||
|
|
@ -54,34 +91,70 @@ export class TmuxPlatformCommandExecutor {
|
|||
}
|
||||
}
|
||||
|
||||
async listPanePids(paneIds: readonly string[]): Promise<Map<string, number>> {
|
||||
async listPaneRuntimeInfo(paneIds: readonly string[]): Promise<Map<string, TmuxPaneRuntimeInfo>> {
|
||||
const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))];
|
||||
if (normalizedPaneIds.length === 0) {
|
||||
return new Map();
|
||||
}
|
||||
|
||||
const result = await this.execTmux(
|
||||
['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'],
|
||||
3_000
|
||||
);
|
||||
const format = [
|
||||
'#{pane_id}',
|
||||
'#{pane_pid}',
|
||||
'#{pane_current_command}',
|
||||
'#{pane_current_path}',
|
||||
'#{session_name}',
|
||||
'#{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 panePidById = new Map<string, number>();
|
||||
const paneInfoById = new Map<string, TmuxPaneRuntimeInfo>();
|
||||
for (const line of result.stdout.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) continue;
|
||||
const [paneId = '', rawPid = ''] = trimmed.split('\t');
|
||||
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) {
|
||||
panePidById.set(normalizedPaneId, pid);
|
||||
paneInfoById.set(normalizedPaneId, {
|
||||
paneId: normalizedPaneId,
|
||||
panePid: pid,
|
||||
currentCommand: currentCommand.trim() || undefined,
|
||||
currentPath: currentPath.trim() || undefined,
|
||||
sessionName: sessionName.trim() || undefined,
|
||||
windowName: windowName.trim() || undefined,
|
||||
});
|
||||
}
|
||||
}
|
||||
return panePidById;
|
||||
return paneInfoById;
|
||||
}
|
||||
|
||||
async listPanePids(paneIds: readonly string[]): Promise<Map<string, number>> {
|
||||
const info = await this.listPaneRuntimeInfo(paneIds);
|
||||
return new Map([...info.entries()].map(([paneId, pane]) => [paneId, pane.panePid]));
|
||||
}
|
||||
|
||||
async listRuntimeProcesses(): Promise<RuntimeProcessTableRow[]> {
|
||||
const result =
|
||||
process.platform === 'win32'
|
||||
? await this.#wslService.execInPreferredDistro(['ps', '-ax', '-o', 'pid=,ppid=,command='])
|
||||
: await this.#execNativePs();
|
||||
if (result.exitCode !== 0) {
|
||||
throw new Error(result.stderr || 'Failed to list runtime processes');
|
||||
}
|
||||
return parseRuntimeProcessTable(result.stdout);
|
||||
}
|
||||
|
||||
killPaneSync(paneId: string): void {
|
||||
|
|
@ -125,6 +198,29 @@ export class TmuxPlatformCommandExecutor {
|
|||
return [...candidates];
|
||||
}
|
||||
|
||||
async #execNativePs(): Promise<ExecResult> {
|
||||
await resolveInteractiveShellEnv();
|
||||
const env = buildEnrichedEnv();
|
||||
return new Promise((resolve) => {
|
||||
execFile(
|
||||
'ps',
|
||||
['-ax', '-o', 'pid=,ppid=,command='],
|
||||
{ env, timeout: 3_000, maxBuffer: 2 * 1024 * 1024 },
|
||||
(error, stdout, stderr) => {
|
||||
const errorCode =
|
||||
typeof error === 'object' && error !== null && 'code' in error
|
||||
? (error as NodeJS.ErrnoException).code
|
||||
: undefined;
|
||||
resolve({
|
||||
exitCode: typeof errorCode === 'number' ? errorCode : error ? 1 : 0,
|
||||
stdout: String(stdout),
|
||||
stderr: String(stderr) || (error instanceof Error ? error.message : ''),
|
||||
});
|
||||
}
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise<string> {
|
||||
const platform =
|
||||
process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32'
|
||||
|
|
|
|||
|
|
@ -78,7 +78,8 @@ describe('TmuxPlatformCommandExecutor', () => {
|
|||
);
|
||||
vi.spyOn(executor, 'execTmux').mockResolvedValue({
|
||||
exitCode: 0,
|
||||
stdout: '%1\t111\n%2\t222\n%3\tnot-a-pid\n',
|
||||
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',
|
||||
stderr: '',
|
||||
});
|
||||
|
||||
|
|
@ -86,8 +87,35 @@ describe('TmuxPlatformCommandExecutor', () => {
|
|||
new Map([['%2', 222]])
|
||||
);
|
||||
expect(executor.execTmux).toHaveBeenCalledWith(
|
||||
['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'],
|
||||
[
|
||||
'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
|
||||
);
|
||||
});
|
||||
|
||||
it('lists runtime processes inside WSL on Windows instead of using host ps', async () => {
|
||||
setPlatform('win32');
|
||||
const execInPreferredDistro = vi.fn(async () => ({
|
||||
exitCode: 0,
|
||||
stdout: ' 42 1 opencode runtime --team-name demo\n',
|
||||
stderr: '',
|
||||
}));
|
||||
const executor = new TmuxPlatformCommandExecutor(
|
||||
{
|
||||
execInPreferredDistro,
|
||||
getPersistedPreferredDistroSync: () => 'Ubuntu',
|
||||
} as never,
|
||||
{} as never
|
||||
);
|
||||
|
||||
await expect(executor.listRuntimeProcesses()).resolves.toEqual([
|
||||
{ pid: 42, ppid: 1, command: 'opencode runtime --team-name demo' },
|
||||
]);
|
||||
expect(execInPreferredDistro).toHaveBeenCalledWith(['ps', '-ax', '-o', 'pid=,ppid=,command=']);
|
||||
expect(childProcess.execFile).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -268,6 +268,23 @@ export class TmuxWslService {
|
|||
return this.#run(['-d', distroName, '-e', 'tmux', ...args], timeout);
|
||||
}
|
||||
|
||||
async execInPreferredDistro(
|
||||
args: string[],
|
||||
preferredDistroName?: string | null,
|
||||
timeout = 5_000
|
||||
): Promise<ExecWslResult> {
|
||||
const distroName = preferredDistroName ?? (await this.probe()).preference?.preferredDistroName;
|
||||
if (!distroName) {
|
||||
return {
|
||||
exitCode: 1,
|
||||
stdout: '',
|
||||
stderr: 'No WSL distribution is available.',
|
||||
};
|
||||
}
|
||||
|
||||
return this.#run(['-d', distroName, '-e', ...args], timeout);
|
||||
}
|
||||
|
||||
getPersistedPreferredDistroSync(): string | null {
|
||||
return this.#preferenceStore.getPreferredDistroSync();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@
|
|||
process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||
|
||||
// Keep userData stable before any integration can initialize Electron storage.
|
||||
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
||||
|
||||
// Sentry must stay near the top to capture early errors after storage migration.
|
||||
import './sentry';
|
||||
|
||||
|
|
@ -118,9 +116,6 @@ import {
|
|||
OpenCodeBridgeCommandHandshakePort,
|
||||
} from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
|
||||
import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
||||
import { resolveOpenCodeTeamLaunchModeFromEnv } from './services/team/opencode/config/OpenCodeLaunchModeEnv';
|
||||
import { resolveOpenCodeProductionE2EEvidencePath } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidencePath';
|
||||
import { OpenCodeProductionE2EEvidenceStore } from './services/team/opencode/e2e/OpenCodeProductionE2EEvidenceStore';
|
||||
import { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||
import {
|
||||
buildTeamControlApiBaseUrl,
|
||||
|
|
@ -147,6 +142,7 @@ import {
|
|||
markRendererUnavailable,
|
||||
safeSendToRenderer,
|
||||
} from './utils/safeWebContentsSend';
|
||||
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
||||
import { syncTelemetryFlag } from './sentry';
|
||||
import {
|
||||
ActiveTeamRegistry,
|
||||
|
|
@ -280,13 +276,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
|||
new OpenCodeTeamRuntimeAdapter(
|
||||
new OpenCodeReadinessBridge(bridgeClient, {
|
||||
stateChangingCommands,
|
||||
productionE2eEvidence: new OpenCodeProductionE2EEvidenceStore({
|
||||
filePath: resolveOpenCodeProductionE2EEvidencePath({ bridgeControlDir }),
|
||||
}),
|
||||
}),
|
||||
{
|
||||
launchMode: resolveOpenCodeTeamLaunchModeFromEnv(),
|
||||
}
|
||||
})
|
||||
),
|
||||
]);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,6 +64,7 @@ import {
|
|||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
TEAM_SKIP_MEMBER_FOR_LAUNCH,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
TEAM_START_TASK,
|
||||
TEAM_START_TASK_BY_USER,
|
||||
|
|
@ -621,6 +622,7 @@ export function registerTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.handle(TEAM_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
|
||||
ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime);
|
||||
ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember);
|
||||
ipcMain.handle(TEAM_SKIP_MEMBER_FOR_LAUNCH, handleSkipMemberForLaunch);
|
||||
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||||
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
||||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
||||
|
|
@ -698,6 +700,7 @@ export function removeTeamHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeHandler(TEAM_MEMBER_SPAWN_STATUSES);
|
||||
ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME);
|
||||
ipcMain.removeHandler(TEAM_RESTART_MEMBER);
|
||||
ipcMain.removeHandler(TEAM_SKIP_MEMBER_FOR_LAUNCH);
|
||||
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||||
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
||||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
||||
|
|
@ -3456,6 +3459,27 @@ async function handleRestartMember(
|
|||
);
|
||||
}
|
||||
|
||||
async function handleSkipMemberForLaunch(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown,
|
||||
memberName: unknown
|
||||
): Promise<IpcResult<void>> {
|
||||
const validatedTeamName = validateTeamName(teamName);
|
||||
if (!validatedTeamName.valid) {
|
||||
return { success: false, error: validatedTeamName.error ?? 'Invalid teamName' };
|
||||
}
|
||||
const validatedMemberName = validateMemberName(memberName);
|
||||
if (!validatedMemberName.valid) {
|
||||
return { success: false, error: validatedMemberName.error ?? 'Invalid memberName' };
|
||||
}
|
||||
return wrapTeamHandler('skipMemberForLaunch', async () =>
|
||||
getTeamProvisioningService().skipMemberForLaunch(
|
||||
validatedTeamName.value!,
|
||||
validatedMemberName.value!
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
async function handleStopTeam(
|
||||
_event: IpcMainInvokeEvent,
|
||||
teamName: unknown
|
||||
|
|
|
|||
|
|
@ -570,6 +570,7 @@ export class ConfigManager {
|
|||
...DEFAULT_CONFIG.general,
|
||||
...(loaded.general ?? {}),
|
||||
};
|
||||
mergedGeneral.multimodelEnabled = true;
|
||||
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
|
||||
|
||||
// Merge triggers: preserve existing triggers, add missing builtin ones
|
||||
|
|
|
|||
|
|
@ -52,6 +52,8 @@ const CATCH_UP_SESSION_RETENTION_MS = 20 * 60 * 1000; // 20 minutes
|
|||
const CATCH_UP_SUBAGENT_RETENTION_MS = 5 * 60 * 1000; // 5 minutes
|
||||
/** Bound best-effort catch-up work per tick so it cannot monopolize the event loop. */
|
||||
const CATCH_UP_SCAN_BUDGET = 24;
|
||||
/** Retire one file from catch-up after repeated local stat timeouts. */
|
||||
const CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT = 3;
|
||||
|
||||
interface AppendedParseResult {
|
||||
messages: ParsedMessage[];
|
||||
|
|
@ -93,6 +95,8 @@ export class FileWatcher extends EventEmitter {
|
|||
private catchUpInProgress = false;
|
||||
/** Round-robin cursor so catch-up work is spread across tracked files. */
|
||||
private catchUpCursor = 0;
|
||||
/** Consecutive catch-up stat timeouts per file. */
|
||||
private catchUpStatFailures = new Map<string, number>();
|
||||
/** Timer for SSH polling mode (replaces fs.watch) */
|
||||
private pollingTimer: NodeJS.Timeout | null = null;
|
||||
/** Polling interval for SSH mode */
|
||||
|
|
@ -232,6 +236,7 @@ export class FileWatcher extends EventEmitter {
|
|||
this.lastProcessedLineCount.clear();
|
||||
this.lastProcessedSize.clear();
|
||||
this.activeSessionFiles.clear();
|
||||
this.catchUpStatFailures.clear();
|
||||
this.processingInProgress.clear();
|
||||
this.pendingReprocess.clear();
|
||||
|
||||
|
|
@ -284,6 +289,7 @@ export class FileWatcher extends EventEmitter {
|
|||
this.lastProcessedLineCount.clear();
|
||||
this.lastProcessedSize.clear();
|
||||
this.activeSessionFiles.clear();
|
||||
this.catchUpStatFailures.clear();
|
||||
this.polledFileSizes.clear();
|
||||
this.processingInProgress.clear();
|
||||
this.pendingReprocess.clear();
|
||||
|
|
@ -867,6 +873,7 @@ export class FileWatcher extends EventEmitter {
|
|||
this.lastProcessedLineCount.delete(filePath);
|
||||
this.lastProcessedSize.delete(filePath);
|
||||
this.activeSessionFiles.delete(filePath);
|
||||
this.catchUpStatFailures.delete(filePath);
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -876,6 +883,7 @@ export class FileWatcher extends EventEmitter {
|
|||
this.lastProcessedLineCount.clear();
|
||||
this.lastProcessedSize.clear();
|
||||
this.activeSessionFiles.clear();
|
||||
this.catchUpStatFailures.clear();
|
||||
this.catchUpCursor = 0;
|
||||
this.catchUpInProgress = false;
|
||||
}
|
||||
|
|
@ -1119,6 +1127,7 @@ export class FileWatcher extends EventEmitter {
|
|||
}
|
||||
|
||||
const stats = await this.fsProvider.stat(filePath);
|
||||
this.catchUpStatFailures.delete(filePath);
|
||||
|
||||
// Skip files not modified recently
|
||||
if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) {
|
||||
|
|
@ -1145,6 +1154,8 @@ export class FileWatcher extends EventEmitter {
|
|||
// File may have been deleted between iterations
|
||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||
this.clearErrorTracking(filePath);
|
||||
} else if (this.isStatTimeoutError(err)) {
|
||||
this.handleCatchUpStatTimeout(filePath);
|
||||
} else {
|
||||
logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err);
|
||||
}
|
||||
|
|
@ -1156,6 +1167,32 @@ export class FileWatcher extends EventEmitter {
|
|||
}
|
||||
}
|
||||
|
||||
private isStatTimeoutError(err: unknown): boolean {
|
||||
return err instanceof Error && err.message === 'stat timeout';
|
||||
}
|
||||
|
||||
private handleCatchUpStatTimeout(filePath: string): void {
|
||||
const failures = (this.catchUpStatFailures.get(filePath) ?? 0) + 1;
|
||||
|
||||
if (failures >= CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT) {
|
||||
logger.warn(
|
||||
`FileWatcher: Retiring ${filePath} from catch-up after ${failures} stat timeouts`
|
||||
);
|
||||
this.retireCatchUpFile(filePath);
|
||||
return;
|
||||
}
|
||||
|
||||
this.catchUpStatFailures.set(filePath, failures);
|
||||
logger.debug(
|
||||
`FileWatcher: Catch-up stat timeout for ${filePath} (${failures}/${CATCH_UP_STAT_TIMEOUT_RETIRE_COUNT})`
|
||||
);
|
||||
}
|
||||
|
||||
private retireCatchUpFile(filePath: string): void {
|
||||
this.activeSessionFiles.delete(filePath);
|
||||
this.catchUpStatFailures.delete(filePath);
|
||||
}
|
||||
|
||||
// ===========================================================================
|
||||
// Debouncing
|
||||
// ===========================================================================
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { once } from 'node:events';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||
|
|
@ -49,6 +48,9 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
|
|||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
||||
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
||||
const DEFAULT_STDIN_CLOSE_TIMEOUT_MS = 250;
|
||||
const DEFAULT_CLOSE_TIMEOUT_MS = 1_000;
|
||||
const DEFAULT_FORCE_CLOSE_TIMEOUT_MS = 1_000;
|
||||
|
||||
export class JsonRpcRequestError extends Error {
|
||||
readonly code: number | null;
|
||||
|
|
@ -76,6 +78,9 @@ export class JsonRpcStdioClient {
|
|||
env?: NodeJS.ProcessEnv;
|
||||
requestTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
stdinCloseTimeoutMs?: number;
|
||||
closeTimeoutMs?: number;
|
||||
forceCloseTimeoutMs?: number;
|
||||
label: string;
|
||||
},
|
||||
handler: (session: JsonRpcSession) => Promise<T>
|
||||
|
|
@ -95,8 +100,14 @@ export class JsonRpcStdioClient {
|
|||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requestTimeoutMs?: number;
|
||||
stdinCloseTimeoutMs?: number;
|
||||
closeTimeoutMs?: number;
|
||||
forceCloseTimeoutMs?: number;
|
||||
}): Promise<JsonRpcSession> {
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const stdinCloseTimeoutMs = options.stdinCloseTimeoutMs ?? DEFAULT_STDIN_CLOSE_TIMEOUT_MS;
|
||||
const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
|
||||
const forceCloseTimeoutMs = options.forceCloseTimeoutMs ?? DEFAULT_FORCE_CLOSE_TIMEOUT_MS;
|
||||
const child = spawnCli(options.binaryPath, options.args, {
|
||||
env: options.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
|
|
@ -194,6 +205,58 @@ export class JsonRpcStdioClient {
|
|||
);
|
||||
});
|
||||
|
||||
const waitForChildClose = (timeoutMs: number): Promise<boolean> => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (closedByEvent: boolean): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
child.off('close', onClose);
|
||||
resolve(closedByEvent);
|
||||
};
|
||||
const onClose = (): void => finish(true);
|
||||
const timeoutId = setTimeout(() => finish(false), timeoutMs);
|
||||
timeoutId.unref?.();
|
||||
|
||||
child.once('close', onClose);
|
||||
});
|
||||
};
|
||||
|
||||
const closeStdin = async (): Promise<void> => {
|
||||
if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
};
|
||||
const timeoutId = setTimeout(finish, stdinCloseTimeoutMs);
|
||||
timeoutId.unref?.();
|
||||
|
||||
try {
|
||||
child.stdin!.end(() => finish());
|
||||
} catch {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
if (closed) {
|
||||
return;
|
||||
|
|
@ -204,21 +267,26 @@ export class JsonRpcStdioClient {
|
|||
notificationListeners.clear();
|
||||
lineReader.close();
|
||||
|
||||
if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) {
|
||||
await new Promise<void>((resolve) => {
|
||||
try {
|
||||
child.stdin!.end(() => resolve());
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
await closeStdin();
|
||||
|
||||
const gracefulClose = waitForChildClose(closeTimeoutMs);
|
||||
killProcessTree(child, 'SIGTERM');
|
||||
if (await gracefulClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
killProcessTree(child);
|
||||
try {
|
||||
await once(child, 'close');
|
||||
} catch {
|
||||
this.logger.warn('json-rpc close wait failed');
|
||||
this.logger.warn('json-rpc close timed out; force killing process', {
|
||||
pid: child.pid,
|
||||
timeoutMs: closeTimeoutMs,
|
||||
});
|
||||
|
||||
const forcedClose = waitForChildClose(forceCloseTimeoutMs);
|
||||
killProcessTree(child, 'SIGKILL');
|
||||
if (!(await forcedClose)) {
|
||||
this.logger.warn('json-rpc force close timed out', {
|
||||
pid: child.pid,
|
||||
timeoutMs: forceCloseTimeoutMs,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { JsonRpcStdioClient } from '../JsonRpcStdioClient';
|
||||
|
||||
|
|
@ -42,6 +42,35 @@ rl.on('line', (line) => {
|
|||
return scriptPath;
|
||||
}
|
||||
|
||||
function createSignalIgnoringJsonRpcServerScript(): string {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-rpc-stdio-client-'));
|
||||
tempDirs.push(tempDir);
|
||||
const scriptPath = path.join(tempDir, 'server.cjs');
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
`
|
||||
const readline = require('node:readline');
|
||||
process.on('SIGTERM', () => {});
|
||||
setInterval(() => {}, 10_000);
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on('line', (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.jsonrpc !== '2.0') {
|
||||
return;
|
||||
}
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result: { ok: true },
|
||||
}) + '\\n');
|
||||
});
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
|
|
@ -76,4 +105,30 @@ describe('JsonRpcStdioClient', () => {
|
|||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('force kills the child when session close does not finish gracefully', async () => {
|
||||
const scriptPath = createSignalIgnoringJsonRpcServerScript();
|
||||
const warn = vi.fn();
|
||||
const client = new JsonRpcStdioClient({ warn });
|
||||
|
||||
await client.withSession(
|
||||
{
|
||||
binaryPath: process.execPath,
|
||||
args: [scriptPath],
|
||||
label: 'stubborn json-rpc close',
|
||||
requestTimeoutMs: 1_000,
|
||||
totalTimeoutMs: 2_000,
|
||||
closeTimeoutMs: 25,
|
||||
forceCloseTimeoutMs: 1_000,
|
||||
},
|
||||
async (session) => {
|
||||
await expect(session.request('ping')).resolves.toEqual({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith('json-rpc close timed out; force killing process', {
|
||||
pid: expect.any(Number),
|
||||
timeoutMs: 25,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -337,10 +337,11 @@ function normalizeBootstrapMemberState(
|
|||
const status = typeof raw.status === 'string' ? raw.status : 'pending';
|
||||
const hardFailure = status === 'failed';
|
||||
const bootstrapConfirmed = status === 'bootstrap_confirmed';
|
||||
const runtimeAlive = bootstrapConfirmed || status === 'runtime_alive';
|
||||
const bootstrapReportedRuntimeAlive = status === 'runtime_alive';
|
||||
const runtimeAlive = bootstrapConfirmed;
|
||||
const agentToolAccepted =
|
||||
bootstrapConfirmed ||
|
||||
runtimeAlive ||
|
||||
bootstrapReportedRuntimeAlive ||
|
||||
status === 'registered' ||
|
||||
status === 'spawn_started' ||
|
||||
hardFailure;
|
||||
|
|
@ -351,7 +352,7 @@ function normalizeBootstrapMemberState(
|
|||
? 'failed_to_start'
|
||||
: bootstrapConfirmed
|
||||
? 'confirmed_alive'
|
||||
: runtimeAlive || agentToolAccepted
|
||||
: agentToolAccepted
|
||||
? 'runtime_pending_bootstrap'
|
||||
: 'starting',
|
||||
agentToolAccepted,
|
||||
|
|
@ -381,10 +382,10 @@ function normalizeBootstrapMemberState(
|
|||
? raw.failureReason.trim()
|
||||
: 'deterministic bootstrap failed',
|
||||
]
|
||||
: runtimeAlive
|
||||
? bootstrapConfirmed
|
||||
: bootstrapConfirmed
|
||||
? ['late heartbeat received']
|
||||
: ['runtime alive', 'waiting for teammate check-in']
|
||||
: bootstrapReportedRuntimeAlive
|
||||
? ['runtime alive reported by bootstrap state', 'waiting for strict live verification']
|
||||
: agentToolAccepted
|
||||
? ['spawn accepted']
|
||||
: undefined,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||
import { getKanbanColumnFromReviewState, getReviewStateFromTask } from '@shared/utils/reviewState';
|
||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
|
|
@ -265,6 +265,11 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null {
|
|||
return body.length > 0 ? body : null;
|
||||
}
|
||||
|
||||
function isExplicitLeadRole(role: string | undefined): boolean {
|
||||
const normalized = role?.trim().toLowerCase();
|
||||
return normalized === 'lead' || normalized === 'team lead' || normalized === 'team-lead';
|
||||
}
|
||||
|
||||
function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
|
||||
return members.some((member) => {
|
||||
if (isLeadMember(member)) {
|
||||
|
|
@ -274,7 +279,7 @@ function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
|
|||
if (normalizedName === 'lead') {
|
||||
return true;
|
||||
}
|
||||
return member.role?.toLowerCase().includes('lead') === true;
|
||||
return isExplicitLeadRole(member.role);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -287,7 +292,7 @@ function hasExplicitLeadInConfig(config: TeamConfig): boolean {
|
|||
if (normalizedName === 'lead') {
|
||||
return true;
|
||||
}
|
||||
return member.role?.toLowerCase().includes('lead') === true;
|
||||
return isExplicitLeadRole(member.role);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -530,16 +535,22 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
private resolveTaskReviewState(
|
||||
task: Pick<TeamTask, 'reviewState'>
|
||||
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
|
||||
kanbanTaskState?: KanbanState['tasks'][string]
|
||||
): 'none' | 'review' | 'needsFix' | 'approved' {
|
||||
return normalizeReviewState(task.reviewState);
|
||||
return getReviewStateFromTask({
|
||||
historyEvents: task.historyEvents,
|
||||
reviewState: task.reviewState,
|
||||
status: task.status,
|
||||
kanbanColumn: kanbanTaskState?.column,
|
||||
});
|
||||
}
|
||||
|
||||
private attachKanbanCompatibility(
|
||||
task: TeamTask,
|
||||
kanbanTaskState?: KanbanState['tasks'][string]
|
||||
): TeamTaskWithKanban {
|
||||
const reviewState = this.resolveTaskReviewState(task);
|
||||
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
||||
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
||||
return {
|
||||
...task,
|
||||
|
|
@ -557,8 +568,15 @@ export class TeamDataService {
|
|||
private resolveReviewerFromHistory(
|
||||
task: TeamTask,
|
||||
kanbanTaskState?: KanbanState['tasks'][string],
|
||||
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(task)
|
||||
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(
|
||||
task,
|
||||
kanbanTaskState
|
||||
)
|
||||
): string | null {
|
||||
if (reviewState !== 'review') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (task.historyEvents?.length) {
|
||||
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
|
||||
const event = task.historyEvents[i];
|
||||
|
|
@ -571,7 +589,10 @@ export class TeamDataService {
|
|||
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
|
||||
break;
|
||||
}
|
||||
if (event.type === 'status_changed' && event.to === 'in_progress') {
|
||||
if (
|
||||
event.type === 'status_changed' &&
|
||||
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (event.type === 'task_created') {
|
||||
|
|
@ -894,7 +915,8 @@ export class TeamDataService {
|
|||
continue;
|
||||
}
|
||||
const info = teamInfoMap.get(task.teamName)!;
|
||||
const reviewState = this.resolveTaskReviewState(task);
|
||||
const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id];
|
||||
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
||||
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
|
||||
|
||||
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
||||
|
|
@ -2158,7 +2180,11 @@ export class TeamDataService {
|
|||
|
||||
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
||||
if (!config) return 'team-lead';
|
||||
const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead'));
|
||||
const members = config.members ?? [];
|
||||
const lead =
|
||||
members.find((member) => isLeadMember(member)) ??
|
||||
members.find((member) => member.name?.trim().toLowerCase() === 'lead') ??
|
||||
members.find((member) => isExplicitLeadRole(member.role));
|
||||
return lead?.name ?? config.members?.[0]?.name ?? 'team-lead';
|
||||
}
|
||||
|
||||
|
|
@ -2731,9 +2757,9 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
this.getController(teamName).review.requestReview(taskId, {
|
||||
from: 'user',
|
||||
from: leadName,
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -3196,15 +3222,15 @@ export class TeamDataService {
|
|||
|
||||
if (patch.op === 'set_column') {
|
||||
if (patch.column === 'review') {
|
||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
controller.review.requestReview(taskId, {
|
||||
from: 'user',
|
||||
from: leadName,
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
} else {
|
||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
controller.review.approveReview(taskId, {
|
||||
from: 'user',
|
||||
from: leadName,
|
||||
suppressTaskComment: true,
|
||||
'notify-owner': true,
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
|
|
@ -3213,9 +3239,9 @@ export class TeamDataService {
|
|||
return;
|
||||
}
|
||||
|
||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
controller.review.requestChanges(taskId, {
|
||||
from: 'user',
|
||||
from: leadName,
|
||||
comment: patch.comment?.trim() || 'Reviewer requested changes.',
|
||||
...(patch.op === 'request_changes' && patch.taskRefs?.length
|
||||
? { taskRefs: patch.taskRefs }
|
||||
|
|
|
|||
|
|
@ -12,6 +12,9 @@ import type {
|
|||
PersistedTeamLaunchSnapshot,
|
||||
PersistedTeamLaunchSummary,
|
||||
ProviderModelLaunchIdentity,
|
||||
TeamAgentRuntimeDiagnosticSeverity,
|
||||
TeamAgentRuntimeLivenessKind,
|
||||
TeamAgentRuntimePidSource,
|
||||
TeamLaunchAggregateState,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -36,9 +39,17 @@ type RuntimeMemberSpawnState = Pick<
|
|||
| 'runtimeAlive'
|
||||
| 'bootstrapConfirmed'
|
||||
| 'hardFailure'
|
||||
| 'skippedForLaunch'
|
||||
| 'skipReason'
|
||||
| 'skippedAt'
|
||||
| 'pendingPermissionRequestIds'
|
||||
| 'livenessKind'
|
||||
| 'runtimeDiagnostic'
|
||||
| 'runtimeDiagnosticSeverity'
|
||||
| 'livenessLastCheckedAt'
|
||||
| 'firstSpawnAcceptedAt'
|
||||
| 'lastHeartbeatAt'
|
||||
| 'runtimeModel'
|
||||
| 'updatedAt'
|
||||
>;
|
||||
|
||||
|
|
@ -59,6 +70,58 @@ function normalizeRuntimePid(value: unknown): number | undefined {
|
|||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | undefined {
|
||||
return value === 'confirmed_bootstrap' ||
|
||||
value === 'runtime_process' ||
|
||||
value === 'runtime_process_candidate' ||
|
||||
value === 'permission_blocked' ||
|
||||
value === 'shell_only' ||
|
||||
value === 'registered_only' ||
|
||||
value === 'stale_metadata' ||
|
||||
value === 'not_found'
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function preservesStrongRuntimeAlive(
|
||||
value:
|
||||
| {
|
||||
runtimeAlive?: boolean;
|
||||
bootstrapConfirmed?: boolean;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
}
|
||||
| undefined
|
||||
): boolean {
|
||||
return (
|
||||
value?.runtimeAlive === true &&
|
||||
(value.bootstrapConfirmed === true ||
|
||||
value.livenessKind === 'confirmed_bootstrap' ||
|
||||
value.livenessKind === 'runtime_process')
|
||||
);
|
||||
}
|
||||
|
||||
function normalizePidSource(value: unknown): TeamAgentRuntimePidSource | undefined {
|
||||
return value === 'lead_process' ||
|
||||
value === 'tmux_pane' ||
|
||||
value === 'tmux_child' ||
|
||||
value === 'agent_process_table' ||
|
||||
value === 'opencode_bridge' ||
|
||||
value === 'runtime_bootstrap' ||
|
||||
value === 'persisted_metadata'
|
||||
? value
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function normalizeDiagnosticSeverity(
|
||||
value: unknown
|
||||
): TeamAgentRuntimeDiagnosticSeverity | undefined {
|
||||
return value === 'info' || value === 'warning' || value === 'error' ? value : undefined;
|
||||
}
|
||||
|
||||
function normalizeOptionalString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
|
||||
}
|
||||
|
||||
function normalizeMemberName(name: string): string {
|
||||
return name.trim();
|
||||
}
|
||||
|
|
@ -70,6 +133,8 @@ function buildDiagnostics(
|
|||
| 'runtimeAlive'
|
||||
| 'bootstrapConfirmed'
|
||||
| 'hardFailureReason'
|
||||
| 'skippedForLaunch'
|
||||
| 'skipReason'
|
||||
| 'sources'
|
||||
| 'pendingPermissionRequestIds'
|
||||
>
|
||||
|
|
@ -85,6 +150,13 @@ function buildDiagnostics(
|
|||
}
|
||||
if (member.hardFailureReason)
|
||||
diagnostics.push(`hard failure reason: ${member.hardFailureReason}`);
|
||||
if (member.skippedForLaunch) {
|
||||
diagnostics.push(
|
||||
member.skipReason
|
||||
? `skipped for this launch: ${member.skipReason}`
|
||||
: 'skipped for this launch'
|
||||
);
|
||||
}
|
||||
if (member.sources?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate');
|
||||
if (member.sources?.configDrift) diagnostics.push('config drift detected');
|
||||
return diagnostics;
|
||||
|
|
@ -99,6 +171,9 @@ export function deriveTeamLaunchAggregateState(
|
|||
if (summary.pendingCount > 0) {
|
||||
return 'partial_pending';
|
||||
}
|
||||
if ((summary.skippedCount ?? 0) > 0) {
|
||||
return 'partial_skipped';
|
||||
}
|
||||
return 'clean_success';
|
||||
}
|
||||
|
||||
|
|
@ -109,7 +184,13 @@ export function summarizePersistedLaunchMembers(
|
|||
let confirmedCount = 0;
|
||||
let pendingCount = 0;
|
||||
let failedCount = 0;
|
||||
let skippedCount = 0;
|
||||
let runtimeAlivePendingCount = 0;
|
||||
let shellOnlyPendingCount = 0;
|
||||
let runtimeProcessPendingCount = 0;
|
||||
let runtimeCandidatePendingCount = 0;
|
||||
let noRuntimePendingCount = 0;
|
||||
let permissionPendingCount = 0;
|
||||
const normalizedExpected = expectedMembers.map(normalizeMemberName).filter(Boolean);
|
||||
const memberNames = Array.from(
|
||||
new Set([
|
||||
|
|
@ -128,17 +209,48 @@ export function summarizePersistedLaunchMembers(
|
|||
confirmedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||
skippedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'failed_to_start') {
|
||||
failedCount += 1;
|
||||
continue;
|
||||
}
|
||||
pendingCount += 1;
|
||||
if (entry.runtimeAlive) {
|
||||
if (preservesStrongRuntimeAlive(entry)) {
|
||||
runtimeAlivePendingCount += 1;
|
||||
}
|
||||
if (entry.launchState === 'runtime_pending_permission') {
|
||||
permissionPendingCount += 1;
|
||||
}
|
||||
if (entry.livenessKind === 'shell_only') {
|
||||
shellOnlyPendingCount += 1;
|
||||
} else if (entry.livenessKind === 'runtime_process') {
|
||||
runtimeProcessPendingCount += 1;
|
||||
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
||||
runtimeCandidatePendingCount += 1;
|
||||
} else if (
|
||||
entry.livenessKind === 'not_found' ||
|
||||
entry.livenessKind === 'stale_metadata' ||
|
||||
entry.livenessKind === 'registered_only'
|
||||
) {
|
||||
noRuntimePendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return { confirmedCount, pendingCount, failedCount, runtimeAlivePendingCount };
|
||||
return {
|
||||
confirmedCount,
|
||||
pendingCount,
|
||||
failedCount,
|
||||
skippedCount,
|
||||
runtimeAlivePendingCount,
|
||||
shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount,
|
||||
runtimeCandidatePendingCount,
|
||||
noRuntimePendingCount,
|
||||
permissionPendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
export function hasMixedPersistedLaunchMetadata(
|
||||
|
|
@ -169,9 +281,13 @@ function deriveMemberLaunchState(
|
|||
| 'bootstrapConfirmed'
|
||||
| 'runtimeAlive'
|
||||
| 'agentToolAccepted'
|
||||
| 'skippedForLaunch'
|
||||
| 'pendingPermissionRequestIds'
|
||||
>
|
||||
): MemberLaunchState {
|
||||
if (member.skippedForLaunch) {
|
||||
return 'skipped_for_launch';
|
||||
}
|
||||
if (member.hardFailure) {
|
||||
return 'failed_to_start';
|
||||
}
|
||||
|
|
@ -299,6 +415,23 @@ function normalizePersistedMemberState(
|
|||
return null;
|
||||
}
|
||||
const providerId = normalizeOptionalTeamProviderId(parsed.providerId);
|
||||
const skippedForLaunch =
|
||||
toBoolean(parsed.skippedForLaunch) || parsed.launchState === 'skipped_for_launch';
|
||||
const bootstrapConfirmed =
|
||||
!skippedForLaunch &&
|
||||
(toBoolean(parsed.bootstrapConfirmed) || parsed.launchState === 'confirmed_alive');
|
||||
const livenessKind = normalizeLivenessKind(parsed.livenessKind);
|
||||
const runtimeAlive = skippedForLaunch
|
||||
? false
|
||||
: preservesStrongRuntimeAlive({
|
||||
runtimeAlive: toBoolean(parsed.runtimeAlive),
|
||||
bootstrapConfirmed,
|
||||
livenessKind,
|
||||
});
|
||||
const sources = normalizeSources(parsed.sources) ?? {};
|
||||
if (!runtimeAlive) {
|
||||
sources.processAlive = undefined;
|
||||
}
|
||||
const next: PersistedTeamLaunchMemberState = {
|
||||
name: normalizedName,
|
||||
providerId,
|
||||
|
|
@ -328,18 +461,28 @@ function normalizePersistedMemberState(
|
|||
laneOwnerProviderId: normalizeOptionalTeamProviderId(parsed.laneOwnerProviderId),
|
||||
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
|
||||
launchState: 'starting',
|
||||
agentToolAccepted: toBoolean(parsed.agentToolAccepted),
|
||||
runtimeAlive: toBoolean(parsed.runtimeAlive),
|
||||
bootstrapConfirmed: toBoolean(parsed.bootstrapConfirmed),
|
||||
hardFailure: toBoolean(parsed.hardFailure),
|
||||
hardFailureReason:
|
||||
typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
||||
skippedForLaunch,
|
||||
skipReason: normalizeOptionalString(parsed.skipReason),
|
||||
skippedAt: normalizeOptionalString(parsed.skippedAt),
|
||||
agentToolAccepted: skippedForLaunch ? false : toBoolean(parsed.agentToolAccepted),
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed,
|
||||
hardFailure: skippedForLaunch ? false : toBoolean(parsed.hardFailure),
|
||||
hardFailureReason: skippedForLaunch
|
||||
? undefined
|
||||
: typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
||||
? parsed.hardFailureReason.trim()
|
||||
: undefined,
|
||||
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
|
||||
parsed.pendingPermissionRequestIds
|
||||
),
|
||||
runtimePid: normalizeRuntimePid(parsed.runtimePid),
|
||||
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
|
||||
livenessKind,
|
||||
pidSource: normalizePidSource(parsed.pidSource),
|
||||
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
|
||||
runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity),
|
||||
runtimeLastSeenAt: normalizeOptionalString(parsed.runtimeLastSeenAt),
|
||||
firstSpawnAcceptedAt:
|
||||
typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined,
|
||||
lastHeartbeatAt:
|
||||
|
|
@ -348,7 +491,7 @@ function normalizePersistedMemberState(
|
|||
typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined,
|
||||
lastEvaluatedAt:
|
||||
typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback,
|
||||
sources: normalizeSources(parsed.sources),
|
||||
sources: Object.values(sources).some(Boolean) ? sources : undefined,
|
||||
diagnostics: Array.isArray(parsed.diagnostics)
|
||||
? parsed.diagnostics.filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0
|
||||
|
|
@ -360,7 +503,8 @@ function normalizePersistedMemberState(
|
|||
parsed.launchState === 'runtime_pending_bootstrap' ||
|
||||
parsed.launchState === 'runtime_pending_permission' ||
|
||||
parsed.launchState === 'confirmed_alive' ||
|
||||
parsed.launchState === 'failed_to_start'
|
||||
parsed.launchState === 'failed_to_start' ||
|
||||
parsed.launchState === 'skipped_for_launch'
|
||||
? parsed.launchState
|
||||
: deriveMemberLaunchState(next);
|
||||
next.launchState = launchState;
|
||||
|
|
@ -402,6 +546,7 @@ export function createPersistedLaunchSnapshot(params: {
|
|||
members[name] = {
|
||||
name,
|
||||
launchState: 'starting',
|
||||
skippedForLaunch: false,
|
||||
agentToolAccepted: false,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
|
|
@ -478,23 +623,39 @@ export function snapshotFromRuntimeMemberStatuses(params: {
|
|||
sources.nativeHeartbeat = true;
|
||||
sources.inboxHeartbeat = true;
|
||||
}
|
||||
if (runtime?.livenessSource === 'process' || runtime?.runtimeAlive) {
|
||||
const skippedForLaunch =
|
||||
runtime?.skippedForLaunch === true || runtime?.launchState === 'skipped_for_launch';
|
||||
const runtimeAlive = skippedForLaunch ? false : preservesStrongRuntimeAlive(runtime);
|
||||
if (runtime?.livenessSource === 'process' && runtimeAlive) {
|
||||
sources.processAlive = true;
|
||||
}
|
||||
const entry: PersistedTeamLaunchMemberState = {
|
||||
name,
|
||||
launchState: runtime?.launchState ?? 'starting',
|
||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||
runtimeAlive: runtime?.runtimeAlive === true,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||
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),
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
livenessKind: runtime?.livenessKind,
|
||||
runtimeDiagnostic: runtime?.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
|
||||
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
|
||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined,
|
||||
lastRuntimeAliveAt: runtimeAlive ? updatedAt : undefined,
|
||||
lastEvaluatedAt: runtime?.updatedAt ?? updatedAt,
|
||||
sources: Object.values(sources).some(Boolean) ? sources : undefined,
|
||||
diagnostics: undefined,
|
||||
|
|
@ -530,8 +691,13 @@ export function snapshotToMemberSpawnStatuses(
|
|||
if (!entry) continue;
|
||||
let status: MemberSpawnStatusEntry['status'] = 'offline';
|
||||
let livenessSource: MemberSpawnLivenessSource | undefined;
|
||||
const skippedForLaunch =
|
||||
entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true;
|
||||
const runtimeAlive = skippedForLaunch ? false : preservesStrongRuntimeAlive(entry);
|
||||
if (entry.launchState === 'failed_to_start') {
|
||||
status = 'error';
|
||||
} else if (entry.launchState === 'skipped_for_launch') {
|
||||
status = 'skipped';
|
||||
} else if (entry.launchState === 'confirmed_alive') {
|
||||
status = 'online';
|
||||
livenessSource = 'heartbeat';
|
||||
|
|
@ -539,8 +705,8 @@ export function snapshotToMemberSpawnStatuses(
|
|||
entry.launchState === 'runtime_pending_permission' ||
|
||||
entry.launchState === 'runtime_pending_bootstrap'
|
||||
) {
|
||||
status = entry.runtimeAlive ? 'online' : 'waiting';
|
||||
livenessSource = entry.runtimeAlive ? 'process' : undefined;
|
||||
status = runtimeAlive ? 'online' : 'waiting';
|
||||
livenessSource = runtimeAlive ? 'process' : undefined;
|
||||
} else {
|
||||
status = entry.agentToolAccepted ? 'waiting' : 'spawning';
|
||||
}
|
||||
|
|
@ -549,12 +715,19 @@ export function snapshotToMemberSpawnStatuses(
|
|||
launchState: entry.launchState,
|
||||
error: entry.hardFailure ? entry.hardFailureReason : undefined,
|
||||
hardFailureReason: entry.hardFailureReason,
|
||||
skippedForLaunch: entry.skippedForLaunch,
|
||||
skipReason: entry.skipReason,
|
||||
skippedAt: entry.skippedAt,
|
||||
livenessSource,
|
||||
agentToolAccepted: entry.agentToolAccepted,
|
||||
runtimeAlive: entry.runtimeAlive,
|
||||
bootstrapConfirmed: entry.bootstrapConfirmed,
|
||||
hardFailure: entry.hardFailure,
|
||||
agentToolAccepted: skippedForLaunch ? false : entry.agentToolAccepted,
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed: skippedForLaunch ? false : entry.bootstrapConfirmed,
|
||||
hardFailure: skippedForLaunch ? false : entry.hardFailure,
|
||||
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
||||
livenessKind: entry.livenessKind,
|
||||
runtimeDiagnostic: entry.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity,
|
||||
livenessLastCheckedAt: entry.runtimeLastSeenAt ?? entry.lastEvaluatedAt,
|
||||
firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: entry.lastHeartbeatAt,
|
||||
updatedAt: entry.lastEvaluatedAt,
|
||||
|
|
|
|||
|
|
@ -13,12 +13,19 @@ export interface LaunchStateSummary {
|
|||
expectedMemberCount?: number;
|
||||
confirmedMemberCount?: number;
|
||||
missingMembers?: string[];
|
||||
skippedMembers?: string[];
|
||||
teamLaunchState?: TeamSummary['teamLaunchState'];
|
||||
launchUpdatedAt?: string;
|
||||
confirmedCount?: number;
|
||||
pendingCount?: number;
|
||||
failedCount?: number;
|
||||
skippedCount?: number;
|
||||
runtimeAlivePendingCount?: number;
|
||||
shellOnlyPendingCount?: number;
|
||||
runtimeProcessPendingCount?: number;
|
||||
runtimeCandidatePendingCount?: number;
|
||||
noRuntimePendingCount?: number;
|
||||
permissionPendingCount?: number;
|
||||
}
|
||||
|
||||
export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary {
|
||||
|
|
@ -55,6 +62,10 @@ export function createLaunchStateSummary(
|
|||
const member = snapshot.members[name];
|
||||
return member?.launchState === 'failed_to_start';
|
||||
});
|
||||
const skippedMembers = persistedMemberNames.filter((name) => {
|
||||
const member = snapshot.members[name];
|
||||
return member?.launchState === 'skipped_for_launch' || member?.skippedForLaunch === true;
|
||||
});
|
||||
|
||||
return {
|
||||
...(snapshot.teamLaunchState === 'partial_failure'
|
||||
|
|
@ -67,12 +78,19 @@ export function createLaunchStateSummary(
|
|||
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
||||
: {}),
|
||||
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
||||
...(skippedMembers.length > 0 ? { skippedMembers } : {}),
|
||||
teamLaunchState: snapshot.teamLaunchState,
|
||||
launchUpdatedAt: snapshot.updatedAt,
|
||||
confirmedCount: snapshot.summary.confirmedCount,
|
||||
pendingCount: snapshot.summary.pendingCount,
|
||||
failedCount: snapshot.summary.failedCount,
|
||||
skippedCount: snapshot.summary.skippedCount,
|
||||
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
|
||||
shellOnlyPendingCount: snapshot.summary.shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount: snapshot.summary.runtimeProcessPendingCount,
|
||||
runtimeCandidatePendingCount: snapshot.summary.runtimeCandidatePendingCount,
|
||||
noRuntimePendingCount: snapshot.summary.noRuntimePendingCount,
|
||||
permissionPendingCount: snapshot.summary.permissionPendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -128,8 +146,17 @@ export function normalizePersistedLaunchSummaryProjection(
|
|||
normalized.missingMembers = missingMembers;
|
||||
}
|
||||
}
|
||||
if (Array.isArray(record.skippedMembers)) {
|
||||
const skippedMembers = record.skippedMembers.filter(
|
||||
(member): member is string => typeof member === 'string' && member.trim().length > 0
|
||||
);
|
||||
if (skippedMembers.length > 0) {
|
||||
normalized.skippedMembers = skippedMembers;
|
||||
}
|
||||
}
|
||||
if (
|
||||
record.teamLaunchState === 'partial_failure' ||
|
||||
record.teamLaunchState === 'partial_skipped' ||
|
||||
record.teamLaunchState === 'partial_pending' ||
|
||||
record.teamLaunchState === 'clean_success'
|
||||
) {
|
||||
|
|
@ -144,9 +171,33 @@ export function normalizePersistedLaunchSummaryProjection(
|
|||
if (typeof record.failedCount === 'number' && record.failedCount >= 0) {
|
||||
normalized.failedCount = record.failedCount;
|
||||
}
|
||||
if (typeof record.skippedCount === 'number' && record.skippedCount >= 0) {
|
||||
normalized.skippedCount = record.skippedCount;
|
||||
}
|
||||
if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) {
|
||||
normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount;
|
||||
}
|
||||
if (typeof record.shellOnlyPendingCount === 'number' && record.shellOnlyPendingCount >= 0) {
|
||||
normalized.shellOnlyPendingCount = record.shellOnlyPendingCount;
|
||||
}
|
||||
if (
|
||||
typeof record.runtimeProcessPendingCount === 'number' &&
|
||||
record.runtimeProcessPendingCount >= 0
|
||||
) {
|
||||
normalized.runtimeProcessPendingCount = record.runtimeProcessPendingCount;
|
||||
}
|
||||
if (
|
||||
typeof record.runtimeCandidatePendingCount === 'number' &&
|
||||
record.runtimeCandidatePendingCount >= 0
|
||||
) {
|
||||
normalized.runtimeCandidatePendingCount = record.runtimeCandidatePendingCount;
|
||||
}
|
||||
if (typeof record.noRuntimePendingCount === 'number' && record.noRuntimePendingCount >= 0) {
|
||||
normalized.noRuntimePendingCount = record.noRuntimePendingCount;
|
||||
}
|
||||
if (typeof record.permissionPendingCount === 'number' && record.permissionPendingCount >= 0) {
|
||||
normalized.permissionPendingCount = record.permissionPendingCount;
|
||||
}
|
||||
normalized.launchUpdatedAt = updatedAt;
|
||||
return normalized;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -227,17 +227,7 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
|||
logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`);
|
||||
}
|
||||
|
||||
// 2. Dev mode — prefer built dist for reliable direct execution
|
||||
const builtEntry = getBuiltServerEntry();
|
||||
checked.push(builtEntry);
|
||||
if (await pathExists(builtEntry)) {
|
||||
return {
|
||||
command: await resolveNodePath(),
|
||||
args: [builtEntry],
|
||||
};
|
||||
}
|
||||
|
||||
// 3. Dev mode fallback — run source directly through a local tsx binary
|
||||
// 2. Dev mode — prefer source so pnpm dev always sees current MCP tools
|
||||
const sourceEntry = getSourceServerEntry();
|
||||
checked.push(sourceEntry);
|
||||
if (await pathExists(sourceEntry)) {
|
||||
|
|
@ -252,6 +242,16 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
|||
}
|
||||
}
|
||||
|
||||
// 3. Dev mode fallback — use built dist when source execution is unavailable
|
||||
const builtEntry = getBuiltServerEntry();
|
||||
checked.push(builtEntry);
|
||||
if (await pathExists(builtEntry)) {
|
||||
return {
|
||||
command: await resolveNodePath(),
|
||||
args: [builtEntry],
|
||||
};
|
||||
}
|
||||
|
||||
throw new Error(
|
||||
`agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}`
|
||||
);
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
383
src/main/services/team/TeamRuntimeLivenessResolver.ts
Normal file
383
src/main/services/team/TeamRuntimeLivenessResolver.ts
Normal file
|
|
@ -0,0 +1,383 @@
|
|||
import type { RuntimeProcessTableRow, TmuxPaneRuntimeInfo } from '@features/tmux-installer/main';
|
||||
import type {
|
||||
MemberSpawnStatusEntry,
|
||||
TeamAgentRuntimeBackendType,
|
||||
TeamAgentRuntimeDiagnosticSeverity,
|
||||
TeamAgentRuntimeLivenessKind,
|
||||
TeamAgentRuntimePidSource,
|
||||
TeamProviderId,
|
||||
} from '@shared/types';
|
||||
|
||||
export interface ResolveTeamMemberRuntimeLivenessInput {
|
||||
teamName: string;
|
||||
memberName: string;
|
||||
agentId?: string;
|
||||
backendType?: TeamAgentRuntimeBackendType;
|
||||
providerId?: TeamProviderId;
|
||||
tmuxPaneId?: string;
|
||||
persistedRuntimePid?: number;
|
||||
persistedRuntimeSessionId?: string;
|
||||
trackedSpawnStatus?: MemberSpawnStatusEntry;
|
||||
runtimePid?: number;
|
||||
runtimeSessionId?: string;
|
||||
pane?: TmuxPaneRuntimeInfo;
|
||||
processRows: readonly RuntimeProcessTableRow[];
|
||||
processTableAvailable: boolean;
|
||||
nowIso: string;
|
||||
}
|
||||
|
||||
export interface ResolvedTeamMemberRuntimeLiveness {
|
||||
alive: boolean;
|
||||
livenessKind: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
pid?: number;
|
||||
metricsPid?: number;
|
||||
panePid?: number;
|
||||
paneCurrentCommand?: string;
|
||||
processCommand?: string;
|
||||
runtimeSessionId?: string;
|
||||
runtimeLastSeenAt?: string;
|
||||
runtimeDiagnostic: string;
|
||||
runtimeDiagnosticSeverity: TeamAgentRuntimeDiagnosticSeverity;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
const SHELL_COMMAND_NAMES = new Set(['sh', 'bash', 'zsh', 'fish', 'dash', 'login', 'tmux']);
|
||||
const SECRET_FLAG_PATTERN =
|
||||
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
|
||||
|
||||
function basenameCommand(command: string | undefined): string {
|
||||
const firstToken = command?.trim().split(/\s+/, 1)[0] ?? '';
|
||||
const base = firstToken.split(/[\\/]/).pop() ?? firstToken;
|
||||
return base.replace(/^-/, '').toLowerCase();
|
||||
}
|
||||
|
||||
export function isShellLikeCommand(command: string | undefined): boolean {
|
||||
return SHELL_COMMAND_NAMES.has(basenameCommand(command));
|
||||
}
|
||||
|
||||
export function sanitizeProcessCommandForDiagnostics(
|
||||
command: string | undefined
|
||||
): string | undefined {
|
||||
const trimmed = command?.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]').slice(0, 500);
|
||||
}
|
||||
|
||||
function escapeRegexLiteral(value: string): string {
|
||||
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function extractCliArgValues(command: string, argName: string): string[] {
|
||||
const escapedArg = escapeRegexLiteral(argName);
|
||||
const pattern = new RegExp(
|
||||
`(?:^|\\s)${escapedArg}(?:=|\\s+)("([^"]*)"|'([^']*)'|([^\\s]+))`,
|
||||
'g'
|
||||
);
|
||||
|
||||
const values: string[] = [];
|
||||
for (const match of command.matchAll(pattern)) {
|
||||
const value = (match[2] ?? match[3] ?? match[4] ?? '').trim();
|
||||
if (value) values.push(value);
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
export function commandArgEquals(
|
||||
command: string,
|
||||
argName: string,
|
||||
expected: string | undefined
|
||||
): boolean {
|
||||
const normalizedExpected = expected?.trim();
|
||||
if (!normalizedExpected) return false;
|
||||
return extractCliArgValues(command, argName).some((value) => value === normalizedExpected);
|
||||
}
|
||||
|
||||
function collectDescendants(
|
||||
rows: readonly RuntimeProcessTableRow[],
|
||||
rootPid: number
|
||||
): RuntimeProcessTableRow[] {
|
||||
const childrenByParent = new Map<number, RuntimeProcessTableRow[]>();
|
||||
for (const row of rows) {
|
||||
const current = childrenByParent.get(row.ppid) ?? [];
|
||||
current.push(row);
|
||||
childrenByParent.set(row.ppid, current);
|
||||
}
|
||||
|
||||
const descendants: RuntimeProcessTableRow[] = [];
|
||||
const queue = [...(childrenByParent.get(rootPid) ?? [])];
|
||||
const seen = new Set<number>();
|
||||
while (queue.length > 0) {
|
||||
const row = queue.shift();
|
||||
if (!row || seen.has(row.pid)) continue;
|
||||
seen.add(row.pid);
|
||||
descendants.push(row);
|
||||
queue.push(...(childrenByParent.get(row.pid) ?? []));
|
||||
}
|
||||
return descendants;
|
||||
}
|
||||
|
||||
function isVerifiedRuntimeProcess(params: {
|
||||
row: RuntimeProcessTableRow;
|
||||
teamName: string;
|
||||
agentId?: string;
|
||||
}): boolean {
|
||||
return (
|
||||
commandArgEquals(params.row.command, '--team-name', params.teamName) &&
|
||||
commandArgEquals(params.row.command, '--agent-id', params.agentId)
|
||||
);
|
||||
}
|
||||
|
||||
function isOpenCodeRuntimeProcess(command: string | undefined): boolean {
|
||||
return (command ?? '').toLowerCase().includes('opencode');
|
||||
}
|
||||
|
||||
function hasPersistedEvidence(input: ResolveTeamMemberRuntimeLivenessInput): boolean {
|
||||
return Boolean(
|
||||
input.agentId?.trim() ||
|
||||
input.tmuxPaneId?.trim() ||
|
||||
input.persistedRuntimePid ||
|
||||
input.runtimePid ||
|
||||
input.persistedRuntimeSessionId?.trim() ||
|
||||
input.runtimeSessionId?.trim() ||
|
||||
input.backendType
|
||||
);
|
||||
}
|
||||
|
||||
function result(params: {
|
||||
alive: boolean;
|
||||
livenessKind: TeamAgentRuntimeLivenessKind;
|
||||
runtimeDiagnostic: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
diagnostics?: string[];
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
pid?: number;
|
||||
metricsPid?: number;
|
||||
panePid?: number;
|
||||
paneCurrentCommand?: string;
|
||||
processCommand?: string;
|
||||
runtimeSessionId?: string;
|
||||
runtimeLastSeenAt?: string;
|
||||
}): ResolvedTeamMemberRuntimeLiveness {
|
||||
return {
|
||||
alive: params.alive,
|
||||
livenessKind: params.livenessKind,
|
||||
runtimeDiagnostic: params.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: params.runtimeDiagnosticSeverity ?? 'info',
|
||||
diagnostics: params.diagnostics ?? [params.runtimeDiagnostic],
|
||||
...(params.pidSource ? { pidSource: params.pidSource } : {}),
|
||||
...(typeof params.pid === 'number' && params.pid > 0 ? { pid: params.pid } : {}),
|
||||
...(typeof params.metricsPid === 'number' && params.metricsPid > 0
|
||||
? { metricsPid: params.metricsPid }
|
||||
: {}),
|
||||
...(typeof params.panePid === 'number' && params.panePid > 0
|
||||
? { panePid: params.panePid }
|
||||
: {}),
|
||||
...(params.paneCurrentCommand ? { paneCurrentCommand: params.paneCurrentCommand } : {}),
|
||||
...(params.processCommand ? { processCommand: params.processCommand } : {}),
|
||||
...(params.runtimeSessionId ? { runtimeSessionId: params.runtimeSessionId } : {}),
|
||||
...(params.runtimeLastSeenAt ? { runtimeLastSeenAt: params.runtimeLastSeenAt } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveTeamMemberRuntimeLiveness(
|
||||
input: ResolveTeamMemberRuntimeLivenessInput
|
||||
): ResolvedTeamMemberRuntimeLiveness {
|
||||
const tracked = input.trackedSpawnStatus;
|
||||
const runtimeSessionId = input.runtimeSessionId ?? input.persistedRuntimeSessionId;
|
||||
const diagnostics: string[] = [];
|
||||
if (!input.processTableAvailable) {
|
||||
diagnostics.push('process table unavailable');
|
||||
}
|
||||
|
||||
if (
|
||||
tracked?.launchState === 'runtime_pending_permission' ||
|
||||
(tracked?.pendingPermissionRequestIds?.length ?? 0) > 0
|
||||
) {
|
||||
return result({
|
||||
alive: false,
|
||||
livenessKind: 'permission_blocked',
|
||||
runtimeSessionId,
|
||||
runtimeDiagnostic: 'waiting for permission approval',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
diagnostics: [...diagnostics, 'permission approval pending'],
|
||||
});
|
||||
}
|
||||
|
||||
const verifiedProcess = input.processRows
|
||||
.filter((row) =>
|
||||
isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId })
|
||||
)
|
||||
.sort((left, right) => right.pid - left.pid)[0];
|
||||
if (verifiedProcess) {
|
||||
return result({
|
||||
alive: true,
|
||||
livenessKind: 'runtime_process',
|
||||
pidSource: 'agent_process_table',
|
||||
pid: verifiedProcess.pid,
|
||||
runtimeSessionId,
|
||||
processCommand: sanitizeProcessCommandForDiagnostics(verifiedProcess.command),
|
||||
runtimeDiagnostic: 'verified runtime process detected',
|
||||
diagnostics: [...diagnostics, 'matched process table by team-name and agent-id'],
|
||||
});
|
||||
}
|
||||
|
||||
const runtimePid = input.runtimePid ?? input.persistedRuntimePid;
|
||||
const runtimePidRow =
|
||||
typeof runtimePid === 'number' && runtimePid > 0
|
||||
? input.processRows.find((row) => row.pid === runtimePid)
|
||||
: undefined;
|
||||
if (runtimePidRow && input.providerId === 'opencode') {
|
||||
const processCommand = sanitizeProcessCommandForDiagnostics(runtimePidRow.command);
|
||||
if (isOpenCodeRuntimeProcess(runtimePidRow.command)) {
|
||||
return result({
|
||||
alive: true,
|
||||
livenessKind: 'runtime_process',
|
||||
pidSource: 'opencode_bridge',
|
||||
pid: runtimePidRow.pid,
|
||||
runtimeSessionId,
|
||||
processCommand,
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected',
|
||||
diagnostics: [...diagnostics, 'matched OpenCode runtime pid and process identity'],
|
||||
});
|
||||
}
|
||||
return result({
|
||||
alive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
pidSource: 'opencode_bridge',
|
||||
pid: runtimePidRow.pid,
|
||||
runtimeSessionId,
|
||||
processCommand,
|
||||
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
diagnostics: [
|
||||
...diagnostics,
|
||||
'matched OpenCode runtime pid without OpenCode process identity',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (tracked?.bootstrapConfirmed === true || tracked?.launchState === 'confirmed_alive') {
|
||||
return result({
|
||||
alive: true,
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
pidSource: 'runtime_bootstrap',
|
||||
runtimeSessionId,
|
||||
runtimeLastSeenAt: tracked.lastHeartbeatAt ?? tracked.updatedAt,
|
||||
runtimeDiagnostic: 'bootstrap confirmed',
|
||||
diagnostics: [...diagnostics, 'bootstrap confirmed'],
|
||||
});
|
||||
}
|
||||
|
||||
const pane = input.pane;
|
||||
if (pane) {
|
||||
const descendants = collectDescendants(input.processRows, pane.panePid);
|
||||
const verifiedDescendant = descendants
|
||||
.filter((row) =>
|
||||
isVerifiedRuntimeProcess({ row, teamName: input.teamName, agentId: input.agentId })
|
||||
)
|
||||
.sort((left, right) => right.pid - left.pid)[0];
|
||||
if (verifiedDescendant) {
|
||||
return result({
|
||||
alive: true,
|
||||
livenessKind: 'runtime_process',
|
||||
pidSource: 'tmux_child',
|
||||
pid: verifiedDescendant.pid,
|
||||
panePid: pane.panePid,
|
||||
paneCurrentCommand: pane.currentCommand,
|
||||
runtimeSessionId,
|
||||
processCommand: sanitizeProcessCommandForDiagnostics(verifiedDescendant.command),
|
||||
runtimeDiagnostic: 'verified tmux runtime child detected',
|
||||
diagnostics: [...diagnostics, 'matched tmux descendant by team-name and agent-id'],
|
||||
});
|
||||
}
|
||||
|
||||
const candidate = descendants.find((row) => !isShellLikeCommand(row.command));
|
||||
if (candidate) {
|
||||
return result({
|
||||
alive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
pidSource: 'tmux_child',
|
||||
pid: candidate.pid,
|
||||
panePid: pane.panePid,
|
||||
paneCurrentCommand: pane.currentCommand,
|
||||
runtimeSessionId,
|
||||
processCommand: sanitizeProcessCommandForDiagnostics(candidate.command),
|
||||
runtimeDiagnostic: 'runtime process candidate detected',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
diagnostics: [...diagnostics, 'tmux descendant found without runtime identity match'],
|
||||
});
|
||||
}
|
||||
|
||||
const shellOnly = isShellLikeCommand(pane.currentCommand);
|
||||
return result({
|
||||
alive: false,
|
||||
livenessKind: shellOnly ? 'shell_only' : 'runtime_process_candidate',
|
||||
pidSource: 'tmux_pane',
|
||||
pid: pane.panePid,
|
||||
panePid: pane.panePid,
|
||||
paneCurrentCommand: pane.currentCommand,
|
||||
runtimeSessionId,
|
||||
runtimeDiagnostic: shellOnly
|
||||
? `tmux pane foreground command is ${pane.currentCommand ?? 'a shell'}`
|
||||
: 'tmux pane is alive, but runtime identity is not verified',
|
||||
runtimeDiagnosticSeverity: shellOnly ? 'warning' : 'info',
|
||||
diagnostics: [
|
||||
...diagnostics,
|
||||
shellOnly
|
||||
? `tmux pane is alive, but foreground command is ${pane.currentCommand ?? 'a shell'}`
|
||||
: 'tmux pane exists, but no verified runtime process was found',
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
if (runtimePid && !runtimePidRow) {
|
||||
if (!input.processTableAvailable) {
|
||||
return result({
|
||||
alive: false,
|
||||
livenessKind: 'registered_only',
|
||||
pidSource: 'persisted_metadata',
|
||||
pid: runtimePid,
|
||||
runtimeSessionId,
|
||||
runtimeDiagnostic: 'runtime pid could not be verified because process table is unavailable',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
diagnostics: [...diagnostics, 'runtime pid could not be verified'],
|
||||
});
|
||||
}
|
||||
return result({
|
||||
alive: false,
|
||||
livenessKind: 'stale_metadata',
|
||||
pidSource: 'persisted_metadata',
|
||||
pid: runtimePid,
|
||||
runtimeSessionId,
|
||||
runtimeDiagnostic: 'persisted runtime pid is not alive',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
diagnostics: [...diagnostics, 'persisted runtime pid was not found in process table'],
|
||||
});
|
||||
}
|
||||
|
||||
if (hasPersistedEvidence(input)) {
|
||||
return result({
|
||||
alive: false,
|
||||
livenessKind: 'registered_only',
|
||||
runtimeSessionId,
|
||||
runtimeDiagnostic: 'registered runtime metadata without live process',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
diagnostics: [...diagnostics, 'member has persisted runtime metadata only'],
|
||||
});
|
||||
}
|
||||
|
||||
return result({
|
||||
alive: false,
|
||||
livenessKind: 'not_found',
|
||||
runtimeDiagnostic: 'runtime process not found',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
diagnostics: [...diagnostics, 'runtime process not found'],
|
||||
});
|
||||
}
|
||||
|
||||
export function isStrongRuntimeEvidence(
|
||||
value: { livenessKind?: TeamAgentRuntimeLivenessKind } | undefined
|
||||
): boolean {
|
||||
return value?.livenessKind === 'confirmed_bootstrap' || value?.livenessKind === 'runtime_process';
|
||||
}
|
||||
|
|
@ -170,6 +170,11 @@ export class TeamTaskReader {
|
|||
completedAt: i.completedAt,
|
||||
}))
|
||||
: undefined;
|
||||
const status = (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
||||
parsed.status as TeamTask['status']
|
||||
)
|
||||
? (parsed.status as TeamTask['status'])
|
||||
: 'pending';
|
||||
const task: TeamTask = {
|
||||
id:
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
|
|
@ -192,11 +197,7 @@ export class TeamTaskReader {
|
|||
promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs),
|
||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
||||
parsed.status as TeamTask['status']
|
||||
)
|
||||
? (parsed.status as TeamTask['status'])
|
||||
: 'pending',
|
||||
status,
|
||||
workIntervals,
|
||||
historyEvents,
|
||||
blocks: Array.isArray(parsed.blocks)
|
||||
|
|
@ -299,6 +300,7 @@ export class TeamTaskReader {
|
|||
reviewState: getReviewStateFromTask({
|
||||
historyEvents,
|
||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||
status,
|
||||
}),
|
||||
sourceMessageId:
|
||||
typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim()
|
||||
|
|
@ -413,6 +415,7 @@ export class TeamTaskReader {
|
|||
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
||||
reviewState: getReviewStateFromTask({
|
||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||
status: 'deleted',
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { configManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
|
||||
|
||||
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator';
|
||||
|
|
@ -18,8 +16,7 @@ export function getConfiguredCliFlavor(): CliFlavor {
|
|||
return envOverride;
|
||||
}
|
||||
|
||||
const multimodelEnabled = configManager.getConfig().general.multimodelEnabled;
|
||||
return multimodelEnabled ? 'agent_teams_orchestrator' : 'claude';
|
||||
return DEFAULT_CLI_FLAVOR;
|
||||
}
|
||||
|
||||
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
|
||||
|
|
|
|||
|
|
@ -22,8 +22,6 @@ export type {
|
|||
export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge';
|
||||
export { ReviewApplierService } from './ReviewApplierService';
|
||||
export type {
|
||||
OpenCodeTeamLaunchMode,
|
||||
OpenCodeTeamRuntimeAdapterOptions,
|
||||
OpenCodeTeamRuntimeBridgePort,
|
||||
TeamLaunchRuntimeAdapter,
|
||||
TeamRuntimeLaunchInput,
|
||||
|
|
|
|||
|
|
@ -15,8 +15,6 @@ export type OpenCodeBridgeCommandName =
|
|||
| 'opencode.getRuntimeTranscript'
|
||||
| 'opencode.recoverDeliveryJournal';
|
||||
|
||||
export type OpenCodeTeamLaunchMode = 'disabled' | 'dogfood' | 'production';
|
||||
|
||||
export type OpenCodeTeamLaunchBridgeState =
|
||||
| 'blocked'
|
||||
| 'launching'
|
||||
|
|
@ -48,7 +46,6 @@ export interface OpenCodeTeamLaunchMemberCommandSpec {
|
|||
}
|
||||
|
||||
export interface OpenCodeLaunchTeamCommandBody {
|
||||
mode: OpenCodeTeamLaunchMode;
|
||||
runId: string;
|
||||
laneId: string;
|
||||
teamId: string;
|
||||
|
|
|
|||
|
|
@ -1,10 +1,3 @@
|
|||
import {
|
||||
assertOpenCodeProductionE2EArtifactGate,
|
||||
buildOpenCodeProjectPathFingerprint,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../e2e/OpenCodeProductionE2EEvidence';
|
||||
import { REQUIRED_AGENT_TEAMS_APP_TOOL_IDS } from '../mcp/OpenCodeMcpToolAvailability';
|
||||
|
||||
import type { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
|
||||
import type {
|
||||
OpenCodeTeamLaunchReadiness,
|
||||
|
|
@ -23,7 +16,6 @@ import type {
|
|||
OpenCodeSendMessageCommandData,
|
||||
OpenCodeStopTeamCommandBody,
|
||||
OpenCodeStopTeamCommandData,
|
||||
OpenCodeTeamLaunchMode,
|
||||
} from './OpenCodeBridgeCommandContract';
|
||||
import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService';
|
||||
|
||||
|
|
@ -48,29 +40,12 @@ export interface OpenCodeReadinessBridgeOptions {
|
|||
sendTimeoutMs?: number;
|
||||
stopTimeoutMs?: number;
|
||||
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
||||
productionE2eEvidence?: OpenCodeProductionE2EEvidenceReadPort;
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceReadPort {
|
||||
read(input?: {
|
||||
selectedModel?: string | null;
|
||||
projectPathFingerprint?: string | null;
|
||||
opencodeVersion?: string | null;
|
||||
binaryFingerprint?: string | null;
|
||||
capabilitySnapshotId?: string | null;
|
||||
}): Promise<{
|
||||
ok: boolean;
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
artifactPath: string;
|
||||
diagnostics: string[];
|
||||
}>;
|
||||
}
|
||||
|
||||
export interface OpenCodeReadinessBridgeCommandBody {
|
||||
projectPath: string;
|
||||
selectedModel: string | null;
|
||||
requireExecutionProbe: boolean;
|
||||
launchMode?: OpenCodeTeamLaunchMode;
|
||||
}
|
||||
|
||||
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
|
||||
|
|
@ -103,11 +78,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
|
||||
if (result.ok) {
|
||||
this.lastRuntimeSnapshotsByProjectPath.set(input.projectPath, result.runtime);
|
||||
return this.applyProductionE2EGate({
|
||||
input,
|
||||
readiness: result.data,
|
||||
runtime: result.runtime,
|
||||
});
|
||||
return result.data;
|
||||
}
|
||||
|
||||
this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath);
|
||||
|
|
@ -122,84 +93,6 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
|||
});
|
||||
}
|
||||
|
||||
private async applyProductionE2EGate(input: {
|
||||
input: OpenCodeReadinessBridgeCommandBody;
|
||||
readiness: OpenCodeTeamLaunchReadiness;
|
||||
runtime: OpenCodeBridgeRuntimeSnapshot;
|
||||
}): Promise<OpenCodeTeamLaunchReadiness> {
|
||||
const launchMode = input.input.launchMode;
|
||||
if (launchMode !== 'production' && launchMode !== 'dogfood') {
|
||||
return input.readiness;
|
||||
}
|
||||
if (!input.readiness.launchAllowed) {
|
||||
return input.readiness;
|
||||
}
|
||||
|
||||
const expectedModel = input.readiness.modelId ?? input.input.selectedModel;
|
||||
const projectPathFingerprint = buildOpenCodeProjectPathFingerprint(input.input.projectPath);
|
||||
const evidenceRead = this.options.productionE2eEvidence
|
||||
? await this.options.productionE2eEvidence.read({
|
||||
selectedModel: expectedModel,
|
||||
projectPathFingerprint,
|
||||
opencodeVersion: input.runtime.version,
|
||||
binaryFingerprint: input.runtime.binaryFingerprint,
|
||||
capabilitySnapshotId: input.runtime.capabilitySnapshotId,
|
||||
})
|
||||
: {
|
||||
ok: false,
|
||||
evidence: null,
|
||||
artifactPath: '',
|
||||
diagnostics: ['OpenCode production E2E evidence store is not configured'],
|
||||
};
|
||||
const gate = evidenceRead.ok
|
||||
? assertOpenCodeProductionE2EArtifactGate({
|
||||
evidence: evidenceRead.evidence,
|
||||
artifactPath: evidenceRead.artifactPath,
|
||||
expected: {
|
||||
opencodeVersion: input.runtime.version,
|
||||
binaryFingerprint: input.runtime.binaryFingerprint,
|
||||
capabilitySnapshotId: input.runtime.capabilitySnapshotId,
|
||||
selectedModel: expectedModel,
|
||||
projectPathFingerprint,
|
||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
||||
},
|
||||
})
|
||||
: {
|
||||
ok: false,
|
||||
diagnostics: evidenceRead.diagnostics,
|
||||
};
|
||||
|
||||
if (gate.ok) {
|
||||
return {
|
||||
...input.readiness,
|
||||
diagnostics: dedupe([...input.readiness.diagnostics, ...evidenceRead.diagnostics]),
|
||||
supportLevel: 'production_supported',
|
||||
};
|
||||
}
|
||||
|
||||
const diagnostics = dedupe([
|
||||
...input.readiness.diagnostics,
|
||||
...evidenceRead.diagnostics,
|
||||
...gate.diagnostics,
|
||||
]);
|
||||
if (launchMode === 'dogfood') {
|
||||
return {
|
||||
...input.readiness,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
...input.readiness,
|
||||
state: 'e2e_missing',
|
||||
launchAllowed: false,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
missing: dedupe([...input.readiness.missing, ...gate.diagnostics]),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
getLastOpenCodeRuntimeSnapshot(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null {
|
||||
return this.lastRuntimeSnapshotsByProjectPath.get(projectPath) ?? null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,17 +0,0 @@
|
|||
import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
|
||||
export const CLAUDE_TEAM_OPENCODE_LAUNCH_MODE_ENV = 'CLAUDE_TEAM_OPENCODE_LAUNCH_MODE';
|
||||
export const CLAUDE_TEAM_OPENCODE_DOGFOOD_ENV = 'CLAUDE_TEAM_OPENCODE_DOGFOOD';
|
||||
|
||||
export function resolveOpenCodeTeamLaunchModeFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): OpenCodeTeamLaunchMode {
|
||||
const raw = env[CLAUDE_TEAM_OPENCODE_LAUNCH_MODE_ENV]?.trim().toLowerCase();
|
||||
if (raw === 'dogfood' || raw === 'production' || raw === 'disabled') {
|
||||
return raw;
|
||||
}
|
||||
if (env[CLAUDE_TEAM_OPENCODE_DOGFOOD_ENV] === '1') {
|
||||
return 'dogfood';
|
||||
}
|
||||
return 'production';
|
||||
}
|
||||
|
|
@ -1,612 +0,0 @@
|
|||
import { createHash } from 'node:crypto';
|
||||
import * as path from 'node:path';
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION = 1;
|
||||
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION = 1;
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS = 7 * 24 * 60 * 60 * 1000;
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS = [
|
||||
'required_tools_proven',
|
||||
'delivery_ready',
|
||||
'member_ready',
|
||||
'run_ready',
|
||||
] as const;
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS = [
|
||||
'app_mcp_tools_visible',
|
||||
'state_changing_launch_completed',
|
||||
'session_records_persisted',
|
||||
'bootstrap_confirmed_alive',
|
||||
'canonical_log_projection_observed',
|
||||
'reconcile_completed',
|
||||
'stop_completed',
|
||||
'stale_run_rejected',
|
||||
] as const;
|
||||
|
||||
export type OpenCodeProductionE2ERequiredSignal =
|
||||
(typeof OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS)[number];
|
||||
|
||||
export interface OpenCodeProductionE2ECheckpointEvidence {
|
||||
name: string;
|
||||
observedAt: string;
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2ESessionEvidence {
|
||||
memberName: string;
|
||||
sessionId: string;
|
||||
launchState: 'confirmed_alive';
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidence {
|
||||
schemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION;
|
||||
evidenceId: string;
|
||||
createdAt: string;
|
||||
expiresAt: string;
|
||||
version: string;
|
||||
passed: boolean;
|
||||
artifactPath: string | null;
|
||||
binaryFingerprint: string;
|
||||
capabilitySnapshotId: string;
|
||||
selectedModel: string;
|
||||
projectPathFingerprint: string | null;
|
||||
requiredSignals: Record<OpenCodeProductionE2ERequiredSignal, boolean>;
|
||||
mcpTools: {
|
||||
requiredTools: readonly string[];
|
||||
observedTools: readonly string[];
|
||||
};
|
||||
launch: {
|
||||
runId: string;
|
||||
teamId: string;
|
||||
teamLaunchState: 'ready';
|
||||
memberCount: number;
|
||||
sessions: OpenCodeProductionE2ESessionEvidence[];
|
||||
durableCheckpoints: OpenCodeProductionE2ECheckpointEvidence[];
|
||||
};
|
||||
reconcile: {
|
||||
runId: string;
|
||||
teamLaunchState: 'ready';
|
||||
memberCount: number;
|
||||
};
|
||||
stop: {
|
||||
runId: string;
|
||||
stopped: true;
|
||||
stoppedSessionIds: string[];
|
||||
};
|
||||
logProjection: {
|
||||
observed: true;
|
||||
projectedMessageCount: number;
|
||||
};
|
||||
diagnostics?: string[];
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceCollection {
|
||||
collectionSchemaVersion: typeof OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION;
|
||||
entriesByModel: Record<string, OpenCodeProductionE2EEvidence>;
|
||||
}
|
||||
|
||||
export type OpenCodeProductionE2EEvidenceStoreData =
|
||||
| OpenCodeProductionE2EEvidence
|
||||
| OpenCodeProductionE2EEvidenceCollection
|
||||
| null;
|
||||
|
||||
export interface OpenCodeProductionE2EGateExpectation {
|
||||
opencodeVersion: string | null;
|
||||
binaryFingerprint: string | null;
|
||||
capabilitySnapshotId: string | null;
|
||||
/**
|
||||
* The currently selected raw model id. Kept for observability and evidence
|
||||
* lookup preference, but not as a hard production-proof gate.
|
||||
*/
|
||||
selectedModel: string | null;
|
||||
projectPathFingerprint?: string | null;
|
||||
requiredMcpTools?: readonly string[];
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EGateResult {
|
||||
ok: boolean;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export function buildOpenCodeProjectPathFingerprint(
|
||||
projectPath: string | null | undefined
|
||||
): string | null {
|
||||
const trimmed = projectPath?.trim() ?? '';
|
||||
if (!trimmed) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const normalized = path.resolve(trimmed).replace(/\\/g, '/');
|
||||
return `project:${createHash('sha256').update(normalized).digest('hex')}`;
|
||||
}
|
||||
|
||||
export function validateOpenCodeProductionE2EEvidence(
|
||||
value: unknown
|
||||
): OpenCodeProductionE2EEvidence {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence must be an object');
|
||||
}
|
||||
|
||||
if (record.schemaVersion !== OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION) {
|
||||
throw new Error('OpenCode production E2E evidence has unsupported schemaVersion');
|
||||
}
|
||||
|
||||
const evidence: OpenCodeProductionE2EEvidence = {
|
||||
schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
evidenceId: requireString(record.evidenceId, 'evidenceId'),
|
||||
createdAt: requireIsoDate(record.createdAt, 'createdAt'),
|
||||
expiresAt: requireIsoDate(record.expiresAt, 'expiresAt'),
|
||||
version: requireString(record.version, 'version'),
|
||||
passed: requireBoolean(record.passed, 'passed'),
|
||||
artifactPath: optionalString(record.artifactPath, 'artifactPath'),
|
||||
binaryFingerprint: requireString(record.binaryFingerprint, 'binaryFingerprint'),
|
||||
capabilitySnapshotId: requireString(record.capabilitySnapshotId, 'capabilitySnapshotId'),
|
||||
selectedModel: requireString(record.selectedModel, 'selectedModel'),
|
||||
projectPathFingerprint: optionalString(record.projectPathFingerprint, 'projectPathFingerprint'),
|
||||
requiredSignals: normalizeRequiredSignals(record.requiredSignals),
|
||||
mcpTools: normalizeMcpTools(record.mcpTools),
|
||||
launch: normalizeLaunch(record.launch),
|
||||
reconcile: normalizeReconcile(record.reconcile),
|
||||
stop: normalizeStop(record.stop),
|
||||
logProjection: normalizeLogProjection(record.logProjection),
|
||||
diagnostics: optionalStringArray(record.diagnostics, 'diagnostics'),
|
||||
};
|
||||
|
||||
return evidence;
|
||||
}
|
||||
|
||||
export function validateNullableOpenCodeProductionE2EEvidence(
|
||||
value: unknown
|
||||
): OpenCodeProductionE2EEvidence | null {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
return validateOpenCodeProductionE2EEvidence(value);
|
||||
}
|
||||
|
||||
export function validateOpenCodeProductionE2EEvidenceStoreData(
|
||||
value: unknown
|
||||
): OpenCodeProductionE2EEvidenceStoreData {
|
||||
if (value === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const record = asRecord(value);
|
||||
if (
|
||||
record?.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION
|
||||
) {
|
||||
return validateOpenCodeProductionE2EEvidenceCollection(record);
|
||||
}
|
||||
|
||||
return validateOpenCodeProductionE2EEvidence(value);
|
||||
}
|
||||
|
||||
export function isOpenCodeProductionE2EEvidenceCollection(
|
||||
value: OpenCodeProductionE2EEvidenceStoreData
|
||||
): value is OpenCodeProductionE2EEvidenceCollection {
|
||||
return (
|
||||
value !== null &&
|
||||
typeof value === 'object' &&
|
||||
'collectionSchemaVersion' in value &&
|
||||
value.collectionSchemaVersion === OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION
|
||||
);
|
||||
}
|
||||
|
||||
export function assertOpenCodeProductionE2EEvidenceBasics(input: {
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
testedVersion: string;
|
||||
now?: Date;
|
||||
artifactPath?: string | null;
|
||||
}): OpenCodeProductionE2EGateResult {
|
||||
const diagnostics: string[] = [];
|
||||
const now = input.now ?? new Date();
|
||||
const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null;
|
||||
|
||||
if (!input.evidence) {
|
||||
return {
|
||||
ok: false,
|
||||
diagnostics: [
|
||||
'OpenCode version is capability-compatible but production E2E evidence is missing',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath));
|
||||
|
||||
if (input.evidence.version !== input.testedVersion) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence version ${input.evidence.version} does not match tested version ${input.testedVersion}`
|
||||
);
|
||||
}
|
||||
|
||||
return {
|
||||
ok: diagnostics.length === 0,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
export function assertOpenCodeProductionE2EArtifactGate(input: {
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
expected: OpenCodeProductionE2EGateExpectation;
|
||||
now?: Date;
|
||||
artifactPath?: string | null;
|
||||
}): OpenCodeProductionE2EGateResult {
|
||||
const diagnostics: string[] = [];
|
||||
const now = input.now ?? new Date();
|
||||
const artifactPath = input.artifactPath ?? input.evidence?.artifactPath ?? null;
|
||||
|
||||
if (!input.evidence) {
|
||||
return {
|
||||
ok: false,
|
||||
diagnostics: [
|
||||
'OpenCode production launch requires a current production E2E evidence artifact',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
diagnostics.push(...collectArtifactShapeDiagnostics(input.evidence, now, artifactPath));
|
||||
diagnostics.push(...collectExpectedRuntimeDiagnostics(input.evidence, input.expected));
|
||||
|
||||
return {
|
||||
ok: diagnostics.length === 0,
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
||||
function collectArtifactShapeDiagnostics(
|
||||
evidence: OpenCodeProductionE2EEvidence,
|
||||
now: Date,
|
||||
artifactPath: string | null
|
||||
): string[] {
|
||||
const diagnostics: string[] = [];
|
||||
const createdAtMs = Date.parse(evidence.createdAt);
|
||||
const expiresAtMs = Date.parse(evidence.expiresAt);
|
||||
|
||||
if (!evidence.passed) {
|
||||
diagnostics.push('OpenCode production E2E evidence did not pass');
|
||||
}
|
||||
|
||||
if (!artifactPath) {
|
||||
diagnostics.push('OpenCode production E2E evidence artifact path is missing');
|
||||
}
|
||||
|
||||
if (!Number.isFinite(createdAtMs)) {
|
||||
diagnostics.push('OpenCode production E2E evidence createdAt is invalid');
|
||||
} else if (now.getTime() - createdAtMs > OPENCODE_PRODUCTION_E2E_EVIDENCE_MAX_AGE_MS) {
|
||||
diagnostics.push('OpenCode production E2E evidence is older than the maximum allowed age');
|
||||
}
|
||||
|
||||
if (!Number.isFinite(expiresAtMs)) {
|
||||
diagnostics.push('OpenCode production E2E evidence expiresAt is invalid');
|
||||
} else if (expiresAtMs <= now.getTime()) {
|
||||
diagnostics.push('OpenCode production E2E evidence is expired');
|
||||
}
|
||||
|
||||
const missingSignals = OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.filter(
|
||||
(signal) => evidence.requiredSignals[signal] !== true
|
||||
);
|
||||
if (missingSignals.length > 0) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence is missing signals: ${missingSignals.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
const checkpointNames = new Set(
|
||||
evidence.launch.durableCheckpoints.map((checkpoint) => checkpoint.name)
|
||||
);
|
||||
const missingCheckpoints = OPENCODE_PRODUCTION_E2E_READY_CHECKPOINTS.filter(
|
||||
(checkpoint) => !checkpointNames.has(checkpoint)
|
||||
);
|
||||
if (missingCheckpoints.length > 0) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence is missing durable checkpoints: ${missingCheckpoints.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
evidence.launch.memberCount <= 0 ||
|
||||
evidence.launch.sessions.length !== evidence.launch.memberCount
|
||||
) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E evidence must include confirmed session evidence for every member'
|
||||
);
|
||||
}
|
||||
|
||||
if (evidence.reconcile.runId !== evidence.launch.runId) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E reconcile evidence runId does not match launch runId'
|
||||
);
|
||||
}
|
||||
|
||||
if (evidence.reconcile.memberCount !== evidence.launch.memberCount) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E reconcile member count does not match launch member count'
|
||||
);
|
||||
}
|
||||
|
||||
if (evidence.stop.runId !== evidence.launch.runId) {
|
||||
diagnostics.push('OpenCode production E2E stop evidence runId does not match launch runId');
|
||||
}
|
||||
|
||||
if (evidence.stop.stoppedSessionIds.length < evidence.launch.sessions.length) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E evidence does not prove every launched session was stopped'
|
||||
);
|
||||
}
|
||||
|
||||
if (evidence.logProjection.projectedMessageCount <= 0) {
|
||||
diagnostics.push('OpenCode production E2E evidence must include projected log messages');
|
||||
}
|
||||
|
||||
const observedTools = new Set(evidence.mcpTools.observedTools);
|
||||
const missingTools = evidence.mcpTools.requiredTools.filter((tool) => !observedTools.has(tool));
|
||||
if (missingTools.length > 0) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence is missing observed MCP tools: ${missingTools.join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function collectExpectedRuntimeDiagnostics(
|
||||
evidence: OpenCodeProductionE2EEvidence,
|
||||
expected: OpenCodeProductionE2EGateExpectation
|
||||
): string[] {
|
||||
const diagnostics: string[] = [];
|
||||
|
||||
if (!expected.opencodeVersion) {
|
||||
diagnostics.push('OpenCode production gate cannot verify runtime version');
|
||||
} else if (evidence.version !== expected.opencodeVersion) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence version ${evidence.version} does not match runtime version ${expected.opencodeVersion}`
|
||||
);
|
||||
}
|
||||
|
||||
if (!expected.binaryFingerprint) {
|
||||
diagnostics.push('OpenCode production gate cannot verify runtime binary fingerprint');
|
||||
} else if (evidence.binaryFingerprint !== expected.binaryFingerprint) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E evidence binary fingerprint does not match runtime binary fingerprint'
|
||||
);
|
||||
}
|
||||
|
||||
if (!expected.capabilitySnapshotId) {
|
||||
diagnostics.push('OpenCode production gate cannot verify capability snapshot id');
|
||||
} else if (evidence.capabilitySnapshotId !== expected.capabilitySnapshotId) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E evidence capability snapshot does not match current runtime'
|
||||
);
|
||||
}
|
||||
|
||||
if (
|
||||
expected.projectPathFingerprint &&
|
||||
evidence.projectPathFingerprint !== expected.projectPathFingerprint
|
||||
) {
|
||||
diagnostics.push(
|
||||
'OpenCode production E2E evidence project context does not match the current working directory'
|
||||
);
|
||||
}
|
||||
|
||||
const requiredTools = expected.requiredMcpTools ?? [];
|
||||
if (requiredTools.length > 0) {
|
||||
const observedTools = new Set(evidence.mcpTools.observedTools);
|
||||
const missingTools = requiredTools.filter((tool) => !observedTools.has(tool));
|
||||
if (missingTools.length > 0) {
|
||||
diagnostics.push(
|
||||
`OpenCode production E2E evidence does not prove required app MCP tools: ${missingTools.join(', ')}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function normalizeRequiredSignals(
|
||||
value: unknown
|
||||
): Record<OpenCodeProductionE2ERequiredSignal, boolean> {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence requiredSignals must be an object');
|
||||
}
|
||||
|
||||
return Object.fromEntries(
|
||||
OPENCODE_PRODUCTION_E2E_REQUIRED_SIGNALS.map((signal) => [
|
||||
signal,
|
||||
requireBoolean(record[signal], `requiredSignals.${signal}`),
|
||||
])
|
||||
) as Record<OpenCodeProductionE2ERequiredSignal, boolean>;
|
||||
}
|
||||
|
||||
function normalizeMcpTools(value: unknown): OpenCodeProductionE2EEvidence['mcpTools'] {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence mcpTools must be an object');
|
||||
}
|
||||
return {
|
||||
requiredTools: requireStringArray(record.requiredTools, 'mcpTools.requiredTools'),
|
||||
observedTools: requireStringArray(record.observedTools, 'mcpTools.observedTools'),
|
||||
};
|
||||
}
|
||||
|
||||
function validateOpenCodeProductionE2EEvidenceCollection(
|
||||
value: Record<string, unknown>
|
||||
): OpenCodeProductionE2EEvidenceCollection {
|
||||
const entriesRecord = asRecord(value.entriesByModel);
|
||||
if (!entriesRecord) {
|
||||
throw new Error('OpenCode production E2E evidence collection entriesByModel must be an object');
|
||||
}
|
||||
|
||||
const entries: Record<string, OpenCodeProductionE2EEvidence> = {};
|
||||
for (const [entryKey, rawEvidence] of Object.entries(entriesRecord)) {
|
||||
const trimmedEntryKey = entryKey.trim();
|
||||
if (!trimmedEntryKey) {
|
||||
throw new Error('OpenCode production E2E evidence collection key must be non-empty');
|
||||
}
|
||||
|
||||
const evidence = validateOpenCodeProductionE2EEvidence(rawEvidence);
|
||||
entries[trimmedEntryKey] = evidence;
|
||||
}
|
||||
|
||||
return {
|
||||
collectionSchemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_COLLECTION_SCHEMA_VERSION,
|
||||
entriesByModel: entries,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLaunch(value: unknown): OpenCodeProductionE2EEvidence['launch'] {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence launch must be an object');
|
||||
}
|
||||
if (record.teamLaunchState !== 'ready') {
|
||||
throw new Error('OpenCode production E2E evidence launch.teamLaunchState must be ready');
|
||||
}
|
||||
return {
|
||||
runId: requireString(record.runId, 'launch.runId'),
|
||||
teamId: requireString(record.teamId, 'launch.teamId'),
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: requirePositiveInteger(record.memberCount, 'launch.memberCount'),
|
||||
sessions: requireArray(record.sessions, 'launch.sessions').map(normalizeSession),
|
||||
durableCheckpoints: requireArray(record.durableCheckpoints, 'launch.durableCheckpoints').map(
|
||||
normalizeCheckpoint
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeSession(value: unknown): OpenCodeProductionE2ESessionEvidence {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence launch session must be an object');
|
||||
}
|
||||
if (record.launchState !== 'confirmed_alive') {
|
||||
throw new Error('OpenCode production E2E evidence launch session must be confirmed_alive');
|
||||
}
|
||||
return {
|
||||
memberName: requireString(record.memberName, 'launch.sessions.memberName'),
|
||||
sessionId: requireString(record.sessionId, 'launch.sessions.sessionId'),
|
||||
launchState: 'confirmed_alive',
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeCheckpoint(value: unknown): OpenCodeProductionE2ECheckpointEvidence {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence durable checkpoint must be an object');
|
||||
}
|
||||
return {
|
||||
name: requireString(record.name, 'launch.durableCheckpoints.name'),
|
||||
observedAt: requireIsoDate(record.observedAt, 'launch.durableCheckpoints.observedAt'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeReconcile(value: unknown): OpenCodeProductionE2EEvidence['reconcile'] {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence reconcile must be an object');
|
||||
}
|
||||
if (record.teamLaunchState !== 'ready') {
|
||||
throw new Error('OpenCode production E2E evidence reconcile.teamLaunchState must be ready');
|
||||
}
|
||||
return {
|
||||
runId: requireString(record.runId, 'reconcile.runId'),
|
||||
teamLaunchState: 'ready',
|
||||
memberCount: requirePositiveInteger(record.memberCount, 'reconcile.memberCount'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeStop(value: unknown): OpenCodeProductionE2EEvidence['stop'] {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence stop must be an object');
|
||||
}
|
||||
if (record.stopped !== true) {
|
||||
throw new Error('OpenCode production E2E evidence stop.stopped must be true');
|
||||
}
|
||||
return {
|
||||
runId: requireString(record.runId, 'stop.runId'),
|
||||
stopped: true,
|
||||
stoppedSessionIds: requireStringArray(record.stoppedSessionIds, 'stop.stoppedSessionIds'),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizeLogProjection(value: unknown): OpenCodeProductionE2EEvidence['logProjection'] {
|
||||
const record = asRecord(value);
|
||||
if (!record) {
|
||||
throw new Error('OpenCode production E2E evidence logProjection must be an object');
|
||||
}
|
||||
if (record.observed !== true) {
|
||||
throw new Error('OpenCode production E2E evidence logProjection.observed must be true');
|
||||
}
|
||||
return {
|
||||
observed: true,
|
||||
projectedMessageCount: requirePositiveInteger(
|
||||
record.projectedMessageCount,
|
||||
'logProjection.projectedMessageCount'
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return typeof value === 'object' && value !== null && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function requireString(value: unknown, field: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function optionalString(value: unknown, field: string): string | null {
|
||||
if (value === null || value === undefined) {
|
||||
return null;
|
||||
}
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be a non-empty string or null`);
|
||||
}
|
||||
return value.trim();
|
||||
}
|
||||
|
||||
function requireBoolean(value: unknown, field: string): boolean {
|
||||
if (typeof value !== 'boolean') {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be boolean`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requirePositiveInteger(value: unknown, field: string): number {
|
||||
if (!Number.isInteger(value) || (value as number) <= 0) {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be a positive integer`);
|
||||
}
|
||||
return value as number;
|
||||
}
|
||||
|
||||
function requireIsoDate(value: unknown, field: string): string {
|
||||
const text = requireString(value, field);
|
||||
if (!Number.isFinite(Date.parse(text))) {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be an ISO timestamp`);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
function requireArray(value: unknown, field: string): unknown[] {
|
||||
if (!Array.isArray(value)) {
|
||||
throw new Error(`OpenCode production E2E evidence ${field} must be an array`);
|
||||
}
|
||||
return value;
|
||||
}
|
||||
|
||||
function requireStringArray(value: unknown, field: string): string[] {
|
||||
return requireArray(value, field).map((item, index) => requireString(item, `${field}[${index}]`));
|
||||
}
|
||||
|
||||
function optionalStringArray(value: unknown, field: string): string[] | undefined {
|
||||
if (value === undefined) {
|
||||
return undefined;
|
||||
}
|
||||
return requireStringArray(value, field);
|
||||
}
|
||||
|
|
@ -1,20 +0,0 @@
|
|||
import { join, resolve } from 'path';
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV =
|
||||
'CLAUDE_TEAM_OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH';
|
||||
|
||||
export const OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE = 'production-e2e-evidence.json';
|
||||
|
||||
export function resolveOpenCodeProductionE2EEvidencePath(input: {
|
||||
bridgeControlDir: string;
|
||||
env?: NodeJS.ProcessEnv;
|
||||
}): string {
|
||||
const env = input.env ?? process.env;
|
||||
const overridePath = env[OPENCODE_PRODUCTION_E2E_EVIDENCE_PATH_ENV]?.trim();
|
||||
|
||||
if (overridePath) {
|
||||
return resolve(overridePath);
|
||||
}
|
||||
|
||||
return join(input.bridgeControlDir, OPENCODE_PRODUCTION_E2E_EVIDENCE_FILE);
|
||||
}
|
||||
|
|
@ -1,253 +0,0 @@
|
|||
import * as path from 'path';
|
||||
|
||||
import { VersionedJsonStore } from '../store/VersionedJsonStore';
|
||||
|
||||
import {
|
||||
isOpenCodeProductionE2EEvidenceCollection,
|
||||
OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
type OpenCodeProductionE2EEvidenceCollection,
|
||||
type OpenCodeProductionE2EEvidenceStoreData,
|
||||
validateOpenCodeProductionE2EEvidence,
|
||||
validateOpenCodeProductionE2EEvidenceStoreData,
|
||||
} from './OpenCodeProductionE2EEvidence';
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceStoreReadResult {
|
||||
ok: boolean;
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
artifactPath: string;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceStoreOptions {
|
||||
filePath: string;
|
||||
clock?: () => Date;
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidenceStoreReadOptions {
|
||||
/**
|
||||
* Preferred exact raw model id when a matching project-scoped proof exists.
|
||||
* Production proof is primarily scoped to the runtime/project integration, not
|
||||
* to a mandatory per-model whitelist.
|
||||
*/
|
||||
selectedModel?: string | null;
|
||||
projectPathFingerprint?: string | null;
|
||||
opencodeVersion?: string | null;
|
||||
binaryFingerprint?: string | null;
|
||||
capabilitySnapshotId?: string | null;
|
||||
}
|
||||
|
||||
export class OpenCodeProductionE2EEvidenceStore {
|
||||
private readonly filePath: string;
|
||||
private readonly store: VersionedJsonStore<OpenCodeProductionE2EEvidenceStoreData>;
|
||||
|
||||
constructor(options: OpenCodeProductionE2EEvidenceStoreOptions) {
|
||||
this.filePath = options.filePath;
|
||||
this.store = new VersionedJsonStore<OpenCodeProductionE2EEvidenceStoreData>({
|
||||
filePath: options.filePath,
|
||||
schemaVersion: OPENCODE_PRODUCTION_E2E_EVIDENCE_SCHEMA_VERSION,
|
||||
defaultData: () => null,
|
||||
validate: validateOpenCodeProductionE2EEvidenceStoreData,
|
||||
clock: options.clock,
|
||||
quarantineDir: path.dirname(options.filePath),
|
||||
});
|
||||
}
|
||||
|
||||
async read(
|
||||
options: OpenCodeProductionE2EEvidenceStoreReadOptions = {}
|
||||
): Promise<OpenCodeProductionE2EEvidenceStoreReadResult> {
|
||||
const result = await this.store.read();
|
||||
if (!result.ok) {
|
||||
return {
|
||||
ok: false,
|
||||
evidence: null,
|
||||
artifactPath: this.filePath,
|
||||
diagnostics: [
|
||||
`OpenCode production E2E evidence store is unreadable: ${result.message}`,
|
||||
...(result.quarantinePath
|
||||
? [`Quarantined corrupt evidence at ${result.quarantinePath}`]
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
const selection = selectEvidence(result.data, options);
|
||||
return {
|
||||
ok: true,
|
||||
evidence: selection.evidence,
|
||||
artifactPath: this.filePath,
|
||||
diagnostics: [
|
||||
...selection.diagnostics,
|
||||
...(result.status === 'missing'
|
||||
? ['OpenCode production E2E evidence artifact has not been written yet']
|
||||
: []),
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
async write(evidence: OpenCodeProductionE2EEvidence): Promise<void> {
|
||||
const validated = validateOpenCodeProductionE2EEvidence(evidence);
|
||||
await this.store.updateLocked((current) => {
|
||||
const nextEvidence = {
|
||||
...validated,
|
||||
artifactPath: validated.artifactPath ?? this.filePath,
|
||||
};
|
||||
return upsertEvidence(current, nextEvidence);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function selectEvidence(
|
||||
data: OpenCodeProductionE2EEvidenceStoreData,
|
||||
options: OpenCodeProductionE2EEvidenceStoreReadOptions
|
||||
): {
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
diagnostics: string[];
|
||||
} {
|
||||
if (!data) {
|
||||
return { evidence: null, diagnostics: [] };
|
||||
}
|
||||
|
||||
if (!isOpenCodeProductionE2EEvidenceCollection(data)) {
|
||||
return { evidence: data, diagnostics: [] };
|
||||
}
|
||||
|
||||
const modelId = options.selectedModel?.trim() ?? '';
|
||||
const projectPathFingerprint = options.projectPathFingerprint?.trim() ?? '';
|
||||
const entries = Object.values(data.entriesByModel);
|
||||
const pickBestForRuntime = (
|
||||
candidates: OpenCodeProductionE2EEvidence[]
|
||||
): OpenCodeProductionE2EEvidence | null => {
|
||||
const runtimeMatched = candidates.filter((entry) => runtimeIdentityMatches(entry, options));
|
||||
return pickNewestEvidence(runtimeMatched.length > 0 ? runtimeMatched : candidates);
|
||||
};
|
||||
|
||||
if (projectPathFingerprint) {
|
||||
const pathEntries = entries.filter(
|
||||
(entry) => entry.projectPathFingerprint === projectPathFingerprint
|
||||
);
|
||||
if (pathEntries.length === 0) {
|
||||
return {
|
||||
evidence: null,
|
||||
diagnostics: [
|
||||
'OpenCode production E2E evidence artifact has no entry for the current working directory',
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
const exactModelMatch = pickBestForRuntime(
|
||||
pathEntries.filter((entry) => entry.selectedModel === modelId)
|
||||
);
|
||||
if (exactModelMatch) {
|
||||
return {
|
||||
evidence: exactModelMatch,
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
evidence: pickBestForRuntime(pathEntries),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (modelId) {
|
||||
const exactModelEntries = entries.filter((entry) => entry.selectedModel === modelId);
|
||||
if (exactModelEntries.length === 0) {
|
||||
return {
|
||||
evidence: null,
|
||||
diagnostics: [
|
||||
`OpenCode production E2E evidence artifact has no entry for selected model ${modelId}`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
evidence: pickNewestEvidence(exactModelEntries),
|
||||
diagnostics: [],
|
||||
};
|
||||
}
|
||||
|
||||
if (entries.length === 1) {
|
||||
return { evidence: entries[0] ?? null, diagnostics: [] };
|
||||
}
|
||||
|
||||
return {
|
||||
evidence: null,
|
||||
diagnostics:
|
||||
entries.length === 0
|
||||
? ['OpenCode production E2E evidence artifact has no model entries']
|
||||
: [
|
||||
`OpenCode production E2E evidence artifact contains ${entries.length} model entries; selected model is required`,
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
function upsertEvidence(
|
||||
current: OpenCodeProductionE2EEvidenceStoreData,
|
||||
evidence: OpenCodeProductionE2EEvidence
|
||||
): OpenCodeProductionE2EEvidenceCollection {
|
||||
const entriesByModel: Record<string, OpenCodeProductionE2EEvidence> = {};
|
||||
if (isOpenCodeProductionE2EEvidenceCollection(current)) {
|
||||
Object.assign(entriesByModel, current.entriesByModel);
|
||||
} else if (current) {
|
||||
entriesByModel[current.selectedModel] = current;
|
||||
}
|
||||
|
||||
entriesByModel[buildEvidenceKey(evidence)] = evidence;
|
||||
return {
|
||||
collectionSchemaVersion: 1,
|
||||
entriesByModel,
|
||||
};
|
||||
}
|
||||
|
||||
function buildEvidenceKey(evidence: OpenCodeProductionE2EEvidence): string {
|
||||
return [evidence.selectedModel, evidence.projectPathFingerprint ?? 'global'].join('::');
|
||||
}
|
||||
|
||||
function runtimeIdentityMatches(
|
||||
evidence: OpenCodeProductionE2EEvidence,
|
||||
options: OpenCodeProductionE2EEvidenceStoreReadOptions
|
||||
): boolean {
|
||||
const expectedVersion = options.opencodeVersion?.trim() ?? '';
|
||||
if (expectedVersion && evidence.version !== expectedVersion) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedBinaryFingerprint = options.binaryFingerprint?.trim() ?? '';
|
||||
if (expectedBinaryFingerprint && evidence.binaryFingerprint !== expectedBinaryFingerprint) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const expectedCapabilitySnapshotId = options.capabilitySnapshotId?.trim() ?? '';
|
||||
if (
|
||||
expectedCapabilitySnapshotId &&
|
||||
evidence.capabilitySnapshotId !== expectedCapabilitySnapshotId
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function pickNewestEvidence(
|
||||
entries: OpenCodeProductionE2EEvidence[]
|
||||
): OpenCodeProductionE2EEvidence | null {
|
||||
if (entries.length === 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return entries.slice(1).reduce<OpenCodeProductionE2EEvidence>((latest, entry) => {
|
||||
const latestAt = Date.parse(latest.createdAt);
|
||||
const entryAt = Date.parse(entry.createdAt);
|
||||
if (!Number.isFinite(entryAt)) {
|
||||
return latest;
|
||||
}
|
||||
if (!Number.isFinite(latestAt) || entryAt >= latestAt) {
|
||||
return entry;
|
||||
}
|
||||
return latest;
|
||||
}, entries[0]);
|
||||
}
|
||||
|
|
@ -2,12 +2,10 @@ import {
|
|||
evaluateOpenCodeSupport,
|
||||
OPENCODE_TEAM_LAUNCH_VERSION_POLICY,
|
||||
type OpenCodeInstallMethod,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
type OpenCodeSupportedVersionPolicy,
|
||||
type OpenCodeSupportLevel,
|
||||
} from '../version/OpenCodeVersionPolicy';
|
||||
|
||||
import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract';
|
||||
import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities';
|
||||
import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability';
|
||||
import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest';
|
||||
|
|
@ -18,7 +16,6 @@ export type OpenCodeTeamLaunchReadinessState =
|
|||
| 'not_authenticated'
|
||||
| 'unsupported_version'
|
||||
| 'capabilities_missing'
|
||||
| 'e2e_missing'
|
||||
| 'runtime_store_blocked'
|
||||
| 'mcp_unavailable'
|
||||
| 'model_unavailable'
|
||||
|
|
@ -98,21 +95,8 @@ export interface OpenCodeModelExecutionProbePort {
|
|||
}): Promise<OpenCodeModelExecutionProbeResult>;
|
||||
}
|
||||
|
||||
export interface OpenCodeProductionE2EEvidencePort {
|
||||
read(input: {
|
||||
projectPath: string;
|
||||
inventory: OpenCodeRuntimeInventory;
|
||||
capabilities: OpenCodeApiCapabilities;
|
||||
}): Promise<OpenCodeProductionE2EEvidence | null>;
|
||||
}
|
||||
|
||||
export interface OpenCodeTeamLaunchReadinessServiceOptions {
|
||||
versionPolicy?: OpenCodeSupportedVersionPolicy;
|
||||
launchMode?: OpenCodeTeamLaunchMode;
|
||||
/**
|
||||
* @deprecated Use launchMode. Kept for callers that still pass a boolean feature gate.
|
||||
*/
|
||||
adapterEnabled?: boolean;
|
||||
}
|
||||
|
||||
export class OpenCodeTeamLaunchReadinessService {
|
||||
|
|
@ -122,7 +106,6 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
private readonly mcpTools: OpenCodeMcpToolProofPort,
|
||||
private readonly runtimeStores: OpenCodeRuntimeStoreReadinessPort,
|
||||
private readonly modelExecution: OpenCodeModelExecutionProbePort,
|
||||
private readonly e2eEvidence: OpenCodeProductionE2EEvidencePort,
|
||||
private readonly options: OpenCodeTeamLaunchReadinessServiceOptions = {}
|
||||
) {}
|
||||
|
||||
|
|
@ -130,21 +113,8 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
projectPath: string;
|
||||
selectedModel: string | null;
|
||||
requireExecutionProbe: boolean;
|
||||
launchMode?: OpenCodeTeamLaunchMode;
|
||||
}): Promise<OpenCodeTeamLaunchReadiness> {
|
||||
const launchMode = resolveReadinessLaunchMode(input.launchMode, this.options);
|
||||
const policy = this.options.versionPolicy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY;
|
||||
const dogfoodWarnings: string[] = [];
|
||||
|
||||
if (launchMode === 'disabled') {
|
||||
return readiness({
|
||||
state: 'adapter_disabled',
|
||||
inventory: null,
|
||||
modelId: input.selectedModel,
|
||||
missing: ['OpenCode team launch adapter is disabled by feature gate'],
|
||||
diagnostics: ['OpenCode team launch adapter is disabled by feature gate'],
|
||||
});
|
||||
}
|
||||
|
||||
try {
|
||||
const inventory = await this.inventory.probe({ projectPath: input.projectPath });
|
||||
|
|
@ -184,24 +154,13 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
projectPath: input.projectPath,
|
||||
inventory,
|
||||
});
|
||||
const evidence = await this.e2eEvidence.read({
|
||||
projectPath: input.projectPath,
|
||||
inventory,
|
||||
capabilities,
|
||||
});
|
||||
const support = evaluateOpenCodeSupport({
|
||||
version: inventory.version ?? '0.0.0',
|
||||
capabilities,
|
||||
evidence,
|
||||
policy,
|
||||
});
|
||||
|
||||
if (!support.supported) {
|
||||
if (launchMode === 'dogfood' && support.supportLevel === 'supported_e2e_pending') {
|
||||
dogfoodWarnings.push(
|
||||
'OpenCode production E2E evidence is missing; dogfood launch remains allowed after runtime checks.'
|
||||
);
|
||||
} else {
|
||||
return readiness({
|
||||
state: mapSupportLevelToReadinessState(support.supportLevel),
|
||||
inventory,
|
||||
|
|
@ -212,7 +171,6 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const runtimeStoreReadiness = await this.runtimeStores.check({
|
||||
projectPath: input.projectPath,
|
||||
|
|
@ -280,7 +238,7 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
runtimeStoreReadiness,
|
||||
supportLevel: support.supportLevel,
|
||||
launchAllowed: true,
|
||||
diagnostics: appendDiagnostics(inventory.diagnostics, dogfoodWarnings),
|
||||
diagnostics: inventory.diagnostics,
|
||||
});
|
||||
} catch (error) {
|
||||
return readiness({
|
||||
|
|
@ -293,22 +251,6 @@ export class OpenCodeTeamLaunchReadinessService {
|
|||
}
|
||||
}
|
||||
|
||||
function resolveReadinessLaunchMode(
|
||||
requested: OpenCodeTeamLaunchMode | undefined,
|
||||
options: OpenCodeTeamLaunchReadinessServiceOptions
|
||||
): OpenCodeTeamLaunchMode {
|
||||
if (requested) {
|
||||
return requested;
|
||||
}
|
||||
if (options.launchMode) {
|
||||
return options.launchMode;
|
||||
}
|
||||
if (options.adapterEnabled === true) {
|
||||
return 'production';
|
||||
}
|
||||
return 'disabled';
|
||||
}
|
||||
|
||||
function readiness(input: {
|
||||
state: OpenCodeTeamLaunchReadinessState;
|
||||
inventory: OpenCodeRuntimeInventory | null;
|
||||
|
|
@ -361,8 +303,6 @@ function mapSupportLevelToReadinessState(
|
|||
return 'unsupported_version';
|
||||
case 'supported_capabilities_pending':
|
||||
return 'capabilities_missing';
|
||||
case 'supported_e2e_pending':
|
||||
return 'e2e_missing';
|
||||
case 'production_supported':
|
||||
return 'ready';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,6 @@
|
|||
import { createHash } from 'crypto';
|
||||
import { promises as fs } from 'fs';
|
||||
|
||||
import {
|
||||
assertOpenCodeProductionE2EEvidenceBasics,
|
||||
type OpenCodeProductionE2EEvidence,
|
||||
} from '../e2e/OpenCodeProductionE2EEvidence';
|
||||
|
||||
import type {
|
||||
OpenCodeApiCapabilities,
|
||||
OpenCodeApiEndpointKey,
|
||||
|
|
@ -14,18 +9,14 @@ import type {
|
|||
|
||||
export interface OpenCodeSupportedVersionPolicy {
|
||||
minimumVersion: string;
|
||||
testedVersion: string;
|
||||
allowedPrerelease: boolean;
|
||||
requireCapabilities: boolean;
|
||||
requireE2EArtifactsForTestedVersion: boolean;
|
||||
}
|
||||
|
||||
export const OPENCODE_TEAM_LAUNCH_VERSION_POLICY: OpenCodeSupportedVersionPolicy = {
|
||||
minimumVersion: '1.14.19',
|
||||
testedVersion: '1.14.19',
|
||||
allowedPrerelease: false,
|
||||
requireCapabilities: true,
|
||||
requireE2EArtifactsForTestedVersion: true,
|
||||
};
|
||||
|
||||
export type OpenCodeInstallMethod = 'brew' | 'npm' | 'bun' | 'manual' | 'unknown';
|
||||
|
|
@ -41,11 +32,8 @@ export type OpenCodeSupportLevel =
|
|||
| 'unsupported_too_old'
|
||||
| 'unsupported_prerelease'
|
||||
| 'supported_capabilities_pending'
|
||||
| 'supported_e2e_pending'
|
||||
| 'production_supported';
|
||||
|
||||
export { type OpenCodeProductionE2EEvidence } from '../e2e/OpenCodeProductionE2EEvidence';
|
||||
|
||||
export interface OpenCodeCompatibilitySnapshot {
|
||||
schemaVersion: 1;
|
||||
createdAt: string;
|
||||
|
|
@ -57,7 +45,6 @@ export interface OpenCodeCompatibilitySnapshot {
|
|||
supported: boolean;
|
||||
supportLevel: OpenCodeSupportLevel;
|
||||
apiCapabilities: OpenCodeApiCapabilities;
|
||||
testedEvidencePath: string | null;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
|
|
@ -121,7 +108,6 @@ export function shouldReuseCompatibilitySnapshot(input: {
|
|||
export function evaluateOpenCodeSupport(input: {
|
||||
version: string;
|
||||
capabilities: OpenCodeApiCapabilities;
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
policy?: OpenCodeSupportedVersionPolicy;
|
||||
}): OpenCodeSupportDecision {
|
||||
const policy = input.policy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY;
|
||||
|
|
@ -157,21 +143,6 @@ export function evaluateOpenCodeSupport(input: {
|
|||
};
|
||||
}
|
||||
|
||||
if (policy.requireE2EArtifactsForTestedVersion) {
|
||||
const evidenceDecision = assertOpenCodeProductionE2EGate({
|
||||
evidence: input.evidence,
|
||||
testedVersion: policy.testedVersion,
|
||||
});
|
||||
if (!evidenceDecision.ok) {
|
||||
return {
|
||||
supported: false,
|
||||
supportLevel: 'supported_e2e_pending',
|
||||
semver: parsed,
|
||||
diagnostics: evidenceDecision.diagnostics,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
supported: true,
|
||||
supportLevel: 'production_supported',
|
||||
|
|
@ -180,14 +151,6 @@ export function evaluateOpenCodeSupport(input: {
|
|||
};
|
||||
}
|
||||
|
||||
export function assertOpenCodeProductionE2EGate(input: {
|
||||
evidence: OpenCodeProductionE2EEvidence | null;
|
||||
testedVersion: string;
|
||||
now?: Date;
|
||||
}): { ok: boolean; diagnostics: string[] } {
|
||||
return assertOpenCodeProductionE2EEvidenceBasics(input);
|
||||
}
|
||||
|
||||
export function selectPermissionReplyRouteFromCache(
|
||||
cache: OpenCodeRouteCompatibilityCache
|
||||
): OpenCodePermissionReplyRoute | null {
|
||||
|
|
|
|||
|
|
@ -12,8 +12,14 @@
|
|||
* diagnostics and completion-time reports.
|
||||
*/
|
||||
|
||||
import type { TeamLaunchDiagnosticItem } from '@shared/types';
|
||||
|
||||
export const PROGRESS_LOG_TAIL_LINES = 200;
|
||||
export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
|
||||
export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20;
|
||||
const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500;
|
||||
const SECRET_FLAG_PATTERN =
|
||||
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
|
||||
|
||||
/**
|
||||
* Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n"
|
||||
|
|
@ -50,3 +56,30 @@ export function buildProgressAssistantOutput(
|
|||
const joined = tail.join('\n\n');
|
||||
return joined.trim().length === 0 ? undefined : joined;
|
||||
}
|
||||
|
||||
function boundDiagnosticText(value: string | undefined): string | undefined {
|
||||
const trimmed = value?.replace(/\s+/g, ' ').trim();
|
||||
if (!trimmed) {
|
||||
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;
|
||||
}
|
||||
|
||||
export function boundLaunchDiagnostics(
|
||||
items: readonly TeamLaunchDiagnosticItem[] | undefined,
|
||||
maxItems: number = PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT
|
||||
): TeamLaunchDiagnosticItem[] | undefined {
|
||||
if (!items || items.length === 0) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const bounded = items.slice(0, Math.max(1, maxItems)).map((item) => ({
|
||||
...item,
|
||||
label: boundDiagnosticText(item.label) ?? item.code,
|
||||
detail: boundDiagnosticText(item.detail),
|
||||
}));
|
||||
return bounded.length > 0 ? bounded : undefined;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -11,7 +11,6 @@ import type {
|
|||
OpenCodeSendMessageCommandData,
|
||||
OpenCodeStopTeamCommandBody,
|
||||
OpenCodeStopTeamCommandData,
|
||||
OpenCodeTeamLaunchMode,
|
||||
OpenCodeTeamMemberLaunchBridgeState,
|
||||
} from '../opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||
|
|
@ -33,7 +32,6 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
|||
projectPath: string;
|
||||
selectedModel: string | null;
|
||||
requireExecutionProbe: boolean;
|
||||
launchMode?: OpenCodeTeamLaunchMode;
|
||||
}): Promise<OpenCodeTeamLaunchReadiness>;
|
||||
getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null;
|
||||
launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise<OpenCodeLaunchTeamCommandData>;
|
||||
|
|
@ -46,14 +44,6 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
|||
): Promise<OpenCodeSendMessageCommandData>;
|
||||
}
|
||||
|
||||
export interface OpenCodeTeamRuntimeAdapterOptions {
|
||||
launchMode?: OpenCodeTeamLaunchMode;
|
||||
/**
|
||||
* @deprecated Use launchMode. Kept for older tests/callers until the production gate is fully wired.
|
||||
*/
|
||||
launchEnabled?: boolean;
|
||||
}
|
||||
|
||||
export interface OpenCodeTeamRuntimeMessageInput {
|
||||
runId?: string;
|
||||
teamName: string;
|
||||
|
|
@ -76,8 +66,6 @@ export interface OpenCodeTeamRuntimeMessageResult {
|
|||
diagnostics: string[];
|
||||
}
|
||||
|
||||
export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract';
|
||||
|
||||
const REQUIRED_READY_CHECKPOINTS = new Set([
|
||||
'required_tools_proven',
|
||||
'delivery_ready',
|
||||
|
|
@ -90,32 +78,14 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
private readonly lastProjectPathByTeamName = new Map<string, string>();
|
||||
private readonly lastReadinessByProjectPath = new Map<string, OpenCodeTeamLaunchReadiness>();
|
||||
|
||||
constructor(
|
||||
private readonly bridge: OpenCodeTeamRuntimeBridgePort,
|
||||
private readonly options: OpenCodeTeamRuntimeAdapterOptions = {}
|
||||
) {}
|
||||
constructor(private readonly bridge: OpenCodeTeamRuntimeBridgePort) {}
|
||||
|
||||
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
|
||||
const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options);
|
||||
if (configuredLaunchMode === 'disabled') {
|
||||
return {
|
||||
ok: false,
|
||||
providerId: this.providerId,
|
||||
reason: 'opencode_team_launch_disabled',
|
||||
retryable: false,
|
||||
diagnostics: [
|
||||
'OpenCode team launch mode is disabled. Set CLAUDE_TEAM_OPENCODE_LAUNCH_MODE=dogfood for local dogfood testing or production after strict readiness evidence exists.',
|
||||
],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
const runtimeOnly = input.runtimeOnly === true;
|
||||
const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({
|
||||
projectPath: input.cwd,
|
||||
selectedModel: input.model ?? null,
|
||||
requireExecutionProbe: !runtimeOnly,
|
||||
launchMode: runtimeOnly ? undefined : configuredLaunchMode,
|
||||
});
|
||||
this.lastReadinessByProjectPath.set(input.cwd, readiness);
|
||||
|
||||
|
|
@ -130,36 +100,12 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
};
|
||||
}
|
||||
|
||||
const warnings =
|
||||
configuredLaunchMode === 'dogfood' && !runtimeOnly
|
||||
? [
|
||||
'OpenCode dogfood launch mode is active. This is local test mode and may run without production E2E evidence.',
|
||||
]
|
||||
: [];
|
||||
|
||||
if (
|
||||
!runtimeOnly &&
|
||||
configuredLaunchMode === 'production' &&
|
||||
readiness.supportLevel !== 'production_supported'
|
||||
) {
|
||||
return {
|
||||
ok: false,
|
||||
providerId: this.providerId,
|
||||
reason: 'opencode_production_e2e_evidence_missing',
|
||||
retryable: false,
|
||||
diagnostics: [
|
||||
'OpenCode production launch requires strict production E2E evidence before enabling team launch.',
|
||||
],
|
||||
warnings,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
ok: true,
|
||||
providerId: this.providerId,
|
||||
modelId: readiness.modelId,
|
||||
diagnostics: readiness.diagnostics,
|
||||
warnings,
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -177,7 +123,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
);
|
||||
}
|
||||
|
||||
const configuredLaunchMode = resolveOpenCodeTeamLaunchMode(this.options);
|
||||
const prepared = await this.prepare(input);
|
||||
if (!prepared.ok) {
|
||||
return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings);
|
||||
|
|
@ -199,7 +144,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null;
|
||||
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
|
||||
const data = await this.bridge.launchOpenCodeTeam({
|
||||
mode: configuredLaunchMode,
|
||||
runId: input.runId,
|
||||
laneId: input.laneId?.trim() || 'primary',
|
||||
teamId: input.teamName,
|
||||
|
|
@ -307,7 +251,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
providerId: this.providerId,
|
||||
launchState: member.launchState,
|
||||
agentToolAccepted: member.agentToolAccepted,
|
||||
runtimeAlive: member.runtimeAlive,
|
||||
runtimeAlive: member.bootstrapConfirmed === true,
|
||||
bootstrapConfirmed: member.bootstrapConfirmed,
|
||||
hardFailure: member.hardFailure,
|
||||
hardFailureReason: member.hardFailureReason,
|
||||
|
|
@ -425,18 +369,6 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
}
|
||||
}
|
||||
|
||||
export function resolveOpenCodeTeamLaunchMode(
|
||||
options: OpenCodeTeamRuntimeAdapterOptions = {}
|
||||
): OpenCodeTeamLaunchMode {
|
||||
if (options.launchMode) {
|
||||
return options.launchMode;
|
||||
}
|
||||
if (options.launchEnabled === true) {
|
||||
return 'production';
|
||||
}
|
||||
return 'disabled';
|
||||
}
|
||||
|
||||
function mapOpenCodeLaunchDataToRuntimeResult(
|
||||
input: TeamRuntimeLaunchInput,
|
||||
data: OpenCodeLaunchTeamCommandData,
|
||||
|
|
@ -549,9 +481,26 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
diagnostics: string[]
|
||||
): TeamRuntimeMemberLaunchEvidence {
|
||||
const confirmed = launchState === 'confirmed_alive';
|
||||
const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked';
|
||||
const failed = launchState === 'failed';
|
||||
const pendingRuntimeObserved = createdOrBlocked && runtimeMaterialized;
|
||||
const hasRuntimePid =
|
||||
typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0;
|
||||
const pendingRuntimeObserved = launchState === 'created' && hasRuntimePid;
|
||||
const livenessKind = confirmed
|
||||
? 'confirmed_bootstrap'
|
||||
: pendingRuntimeObserved
|
||||
? 'runtime_process_candidate'
|
||||
: launchState === 'permission_blocked'
|
||||
? 'permission_blocked'
|
||||
: runtimeMaterialized || sessionId
|
||||
? 'runtime_process_candidate'
|
||||
: 'registered_only';
|
||||
const runtimeDiagnostic = pendingRuntimeObserved
|
||||
? 'OpenCode runtime pid reported by bridge without local process verification'
|
||||
: launchState === 'permission_blocked'
|
||||
? 'OpenCode runtime is waiting for permission approval'
|
||||
: runtimeMaterialized || sessionId
|
||||
? 'OpenCode session exists without verified runtime pid'
|
||||
: undefined;
|
||||
return {
|
||||
memberName,
|
||||
providerId: 'opencode',
|
||||
|
|
@ -562,8 +511,13 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
: launchState === 'permission_blocked'
|
||||
? 'runtime_pending_permission'
|
||||
: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: confirmed || pendingRuntimeObserved,
|
||||
runtimeAlive: confirmed || pendingRuntimeObserved,
|
||||
agentToolAccepted:
|
||||
confirmed ||
|
||||
pendingRuntimeObserved ||
|
||||
launchState === 'permission_blocked' ||
|
||||
runtimeMaterialized ||
|
||||
Boolean(sessionId),
|
||||
runtimeAlive: confirmed,
|
||||
bootstrapConfirmed: confirmed,
|
||||
hardFailure: failed,
|
||||
hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined,
|
||||
|
|
@ -572,9 +526,10 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
? [...new Set(pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
sessionId,
|
||||
...(typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0
|
||||
? { runtimePid }
|
||||
: {}),
|
||||
...(hasRuntimePid ? { runtimePid } : {}),
|
||||
livenessKind,
|
||||
...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}),
|
||||
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||
diagnostics,
|
||||
};
|
||||
}
|
||||
|
|
@ -709,7 +664,6 @@ function isRetryableReadinessState(state: OpenCodeTeamLaunchReadiness['state']):
|
|||
return (
|
||||
state === 'not_installed' ||
|
||||
state === 'not_authenticated' ||
|
||||
state === 'e2e_missing' ||
|
||||
state === 'runtime_store_blocked' ||
|
||||
state === 'mcp_unavailable' ||
|
||||
state === 'model_unavailable' ||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import type {
|
|||
PersistedTeamLaunchPhase,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
TeamAgentRuntimeBackendType,
|
||||
TeamAgentRuntimeLivenessKind,
|
||||
TeamAgentRuntimePidSource,
|
||||
TeamLaunchAggregateState,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -73,6 +75,9 @@ export interface TeamRuntimeMemberLaunchEvidence {
|
|||
sessionId?: string;
|
||||
backendType?: TeamAgentRuntimeBackendType;
|
||||
runtimePid?: number;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
diagnostics: string[];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,6 +1,4 @@
|
|||
export type {
|
||||
OpenCodeTeamLaunchMode,
|
||||
OpenCodeTeamRuntimeAdapterOptions,
|
||||
OpenCodeTeamRuntimeBridgePort,
|
||||
OpenCodeTeamRuntimeMessageInput,
|
||||
OpenCodeTeamRuntimeMessageResult,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ export function encodePath(absolutePath: string): string {
|
|||
}
|
||||
|
||||
const encoded = absolutePath.replace(/[/\\]/g, '-');
|
||||
const windowsDriveMatch = /^([a-zA-Z]):-(.*)$/.exec(encoded);
|
||||
if (windowsDriveMatch) {
|
||||
return `${windowsDriveMatch[1].toUpperCase()}--${windowsDriveMatch[2]}`;
|
||||
}
|
||||
|
||||
// Ensure leading dash for absolute paths
|
||||
return encoded.startsWith('-') ? encoded : `-${encoded}`;
|
||||
|
|
@ -50,7 +54,7 @@ export function decodePath(encodedName: string): string {
|
|||
|
||||
// Legacy Windows format observed in some Claude installs: "C--Users-name-project"
|
||||
// (no leading dash, drive separator encoded as "--").
|
||||
const legacyWindowsRegex = /^([a-zA-Z])--(.+)$/;
|
||||
const legacyWindowsRegex = /^([a-zA-Z])--(.*)$/;
|
||||
const legacyWindowsMatch = legacyWindowsRegex.exec(encodedName);
|
||||
if (legacyWindowsMatch) {
|
||||
const drive = legacyWindowsMatch[1].toUpperCase();
|
||||
|
|
|
|||
|
|
@ -97,6 +97,13 @@ interface TaskReadDiag {
|
|||
|
||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
||||
const REVIEW_LIFECYCLE_EVENTS = new Set([
|
||||
'review_requested',
|
||||
'review_changes_requested',
|
||||
'review_approved',
|
||||
'review_started',
|
||||
]);
|
||||
const REVIEW_RESET_STATUSES = new Set(['in_progress', 'deleted']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsed JSON types (loose shapes from disk)
|
||||
|
|
@ -743,26 +750,64 @@ function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefin
|
|||
.map((i) => ({ ...i }));
|
||||
}
|
||||
|
||||
function normalizeReviewState(value: unknown): string {
|
||||
return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none';
|
||||
}
|
||||
|
||||
function normalizeFallbackReviewState(value: unknown, status: string): string {
|
||||
const reviewState = normalizeReviewState(value);
|
||||
if (reviewState === 'none') return 'none';
|
||||
if (status === 'in_progress' || status === 'deleted') return 'none';
|
||||
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
||||
if (status === 'completed') {
|
||||
return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none';
|
||||
}
|
||||
return reviewState;
|
||||
}
|
||||
|
||||
function eventReviewState(event: RawHistoryEvent): string | null {
|
||||
const type = typeof event.type === 'string' ? event.type : '';
|
||||
if (!REVIEW_LIFECYCLE_EVENTS.has(type)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeReviewState(event.to);
|
||||
}
|
||||
|
||||
function derivePendingReviewState(events: RawHistoryEvent[], startIndex: number): string {
|
||||
for (let i = startIndex - 1; i >= 0; i--) {
|
||||
const previous = events[i];
|
||||
const reviewState = eventReviewState(previous);
|
||||
if (reviewState) {
|
||||
return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
||||
}
|
||||
if (
|
||||
previous.type === 'task_created' ||
|
||||
(previous.type === 'status_changed' &&
|
||||
(REVIEW_RESET_STATUSES.has(String(previous.to || '')) || previous.to === 'pending'))
|
||||
) {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** Derive review state from historyEvents (inline reducer for worker isolation). */
|
||||
function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string {
|
||||
if (!Array.isArray(events) || events.length === 0) return 'none';
|
||||
function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string | null {
|
||||
if (!Array.isArray(events) || events.length === 0) return null;
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
const t = e.type;
|
||||
if (
|
||||
t === 'review_requested' ||
|
||||
t === 'review_changes_requested' ||
|
||||
t === 'review_approved' ||
|
||||
t === 'review_started'
|
||||
) {
|
||||
const to = typeof e.to === 'string' ? e.to : 'none';
|
||||
return to === 'review' || to === 'needsFix' || to === 'approved' ? to : 'none';
|
||||
const reviewState = eventReviewState(e);
|
||||
if (reviewState) {
|
||||
return reviewState;
|
||||
}
|
||||
if (t === 'status_changed' && e.to === 'in_progress') {
|
||||
if (e.type === 'status_changed' && REVIEW_RESET_STATUSES.has(String(e.to || ''))) {
|
||||
return 'none';
|
||||
}
|
||||
if (e.type === 'status_changed' && e.to === 'pending') {
|
||||
return derivePendingReviewState(events, i);
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
||||
|
|
@ -869,7 +914,16 @@ async function readTasksDirForTeam(
|
|||
? (parsed.needsClarification as string)
|
||||
: undefined;
|
||||
const historyEvents = normalizeHistoryEvents(parsed);
|
||||
const reviewState = deriveReviewStateFromEvents(historyEvents);
|
||||
const status =
|
||||
parsed.status === 'pending' ||
|
||||
parsed.status === 'in_progress' ||
|
||||
parsed.status === 'completed' ||
|
||||
parsed.status === 'deleted'
|
||||
? (parsed.status as string)
|
||||
: 'pending';
|
||||
const reviewState =
|
||||
deriveReviewStateFromEvents(historyEvents) ??
|
||||
normalizeFallbackReviewState(parsed.reviewState, status);
|
||||
|
||||
tasks.push({
|
||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
|
|
@ -896,13 +950,7 @@ async function readTasksDirForTeam(
|
|||
: undefined,
|
||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||
status:
|
||||
parsed.status === 'pending' ||
|
||||
parsed.status === 'in_progress' ||
|
||||
parsed.status === 'completed' ||
|
||||
parsed.status === 'deleted'
|
||||
? (parsed.status as string)
|
||||
: 'pending',
|
||||
status,
|
||||
workIntervals: normalizeWorkIntervals(parsed),
|
||||
historyEvents: normalizeHistoryEvents(parsed),
|
||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
||||
|
|
|
|||
|
|
@ -385,6 +385,9 @@ export const TEAM_GET_AGENT_RUNTIME = 'team:getAgentRuntime';
|
|||
/** Restart a specific teammate runtime */
|
||||
export const TEAM_RESTART_MEMBER = 'team:restartMember';
|
||||
|
||||
/** Skip a failed teammate for the current launch */
|
||||
export const TEAM_SKIP_MEMBER_FOR_LAUNCH = 'team:skipMemberForLaunch';
|
||||
|
||||
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
|
||||
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
||||
|
||||
|
|
|
|||
|
|
@ -171,6 +171,7 @@ import {
|
|||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||
TEAM_SKIP_MEMBER_FOR_LAUNCH,
|
||||
TEAM_SOFT_DELETE_TASK,
|
||||
TEAM_START_TASK,
|
||||
TEAM_START_TASK_BY_USER,
|
||||
|
|
@ -1093,6 +1094,9 @@ const electronAPI: ElectronAPI = {
|
|||
restartMember: async (teamName: string, memberName: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_RESTART_MEMBER, teamName, memberName);
|
||||
},
|
||||
skipMemberForLaunch: async (teamName: string, memberName: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SKIP_MEMBER_FOR_LAUNCH, teamName, memberName);
|
||||
},
|
||||
softDeleteTask: async (teamName: string, taskId: string) => {
|
||||
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -959,6 +959,9 @@ export class HttpAPIClient implements ElectronAPI {
|
|||
restartMember: async (): Promise<void> => {
|
||||
throw new Error('Member restart is not available in browser mode');
|
||||
},
|
||||
skipMemberForLaunch: async (): Promise<void> => {
|
||||
throw new Error('Member launch skip is not available in browser mode');
|
||||
},
|
||||
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||
// Not available via HTTP client — no-op
|
||||
},
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import {
|
|||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
||||
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||
|
|
@ -265,11 +264,8 @@ interface InstalledBannerProps {
|
|||
cliStatusError: string | null;
|
||||
providersCollapsed: boolean;
|
||||
isBusy: boolean;
|
||||
multimodelEnabled: boolean;
|
||||
multimodelBusy: boolean;
|
||||
onInstall: () => void;
|
||||
onRefresh: () => void;
|
||||
onMultimodelToggle: (enabled: boolean) => void;
|
||||
onToggleProvidersCollapsed: () => void;
|
||||
onProviderLogin: (providerId: CliProviderId) => void;
|
||||
onProviderLogout: (providerId: CliProviderId) => void;
|
||||
|
|
@ -573,11 +569,8 @@ const InstalledBanner = ({
|
|||
cliStatusError,
|
||||
providersCollapsed,
|
||||
isBusy,
|
||||
multimodelEnabled,
|
||||
multimodelBusy,
|
||||
onInstall,
|
||||
onRefresh,
|
||||
onMultimodelToggle,
|
||||
onToggleProvidersCollapsed,
|
||||
onProviderLogin,
|
||||
onProviderLogout,
|
||||
|
|
@ -683,23 +676,6 @@ const InstalledBanner = ({
|
|||
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Multimodel
|
||||
</span>
|
||||
{multimodelEnabled && (
|
||||
<span
|
||||
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.35)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
<SettingsToggle
|
||||
enabled={multimodelEnabled}
|
||||
onChange={onMultimodelToggle}
|
||||
disabled={isBusy || cliStatusLoading || multimodelBusy}
|
||||
/>
|
||||
</div>
|
||||
{/* Extensions button — available whenever the runtime is installed */}
|
||||
{canOpenExtensions && (
|
||||
|
|
@ -1033,7 +1009,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('anthropic');
|
||||
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
||||
const [providersCollapsed, setProvidersCollapsed] = useState(() =>
|
||||
loadDashboardCliStatusBannerCollapsed()
|
||||
|
|
@ -1147,37 +1122,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
})();
|
||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
const handleMultimodelToggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
setIsSwitchingFlavor(true);
|
||||
let nextMultimodelEnabled = multimodelEnabled;
|
||||
try {
|
||||
useStore.setState({
|
||||
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
|
||||
cliStatusLoading: true,
|
||||
cliStatusError: null,
|
||||
});
|
||||
await updateConfig('general', { multimodelEnabled: enabled });
|
||||
nextMultimodelEnabled = enabled;
|
||||
await invalidateCliStatus();
|
||||
if (enabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} catch {
|
||||
if (nextMultimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsSwitchingFlavor(false);
|
||||
}
|
||||
},
|
||||
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig]
|
||||
);
|
||||
|
||||
const recheckAuthState = useCallback(() => {
|
||||
setIsVerifyingAuth(true);
|
||||
void (async () => {
|
||||
|
|
@ -1422,11 +1366,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
|
|
@ -1650,11 +1591,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
|
|
@ -1712,11 +1650,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
|
|
@ -1934,11 +1869,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import {
|
|||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
|
@ -211,7 +210,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
} | null>(null);
|
||||
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
|
||||
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||
const loadingCliStatus =
|
||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||
|
|
@ -323,37 +321,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
})();
|
||||
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
|
||||
|
||||
const handleMultimodelToggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
setIsSwitchingFlavor(true);
|
||||
let nextMultimodelEnabled = multimodelEnabled;
|
||||
try {
|
||||
useStore.setState({
|
||||
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
|
||||
cliStatusLoading: true,
|
||||
cliStatusError: null,
|
||||
});
|
||||
await updateConfig('general', { multimodelEnabled: enabled });
|
||||
nextMultimodelEnabled = enabled;
|
||||
await invalidateCliStatus();
|
||||
if (enabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} catch {
|
||||
if (nextMultimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsSwitchingFlavor(false);
|
||||
}
|
||||
},
|
||||
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig]
|
||||
);
|
||||
|
||||
const handleRuntimeBackendChange = useCallback(
|
||||
async (providerId: CliProviderId, backendId: string) => {
|
||||
const currentBackends = appConfig?.runtime?.providerBackends ?? {
|
||||
|
|
@ -440,23 +407,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
>
|
||||
Multimodel
|
||||
</span>
|
||||
{multimodelEnabled && (
|
||||
<span
|
||||
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.35)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
<SettingsToggle
|
||||
enabled={multimodelEnabled}
|
||||
onChange={(value) => void handleMultimodelToggle(value)}
|
||||
disabled={isBusy || cliStatusLoading || isSwitchingFlavor}
|
||||
/>
|
||||
</div>
|
||||
{/* Inline action buttons */}
|
||||
{effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (
|
||||
|
|
|
|||
|
|
@ -19,6 +19,7 @@ import { DISPLAY_STEPS } from './provisioningSteps';
|
|||
import { StepProgressBar } from './StepProgressBar';
|
||||
|
||||
import type { StepProgressBarStep } from './StepProgressBar';
|
||||
import type { TeamLaunchDiagnosticItem } from '@shared/types';
|
||||
|
||||
/** Pre-built step definitions for the provisioning stepper. */
|
||||
const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
|
||||
|
|
@ -61,6 +62,8 @@ export interface ProvisioningProgressBlockProps {
|
|||
cliLogsTail?: string;
|
||||
/** Accumulated assistant text output for live preview */
|
||||
assistantOutput?: string;
|
||||
/** Bounded structured launch diagnostics */
|
||||
launchDiagnostics?: TeamLaunchDiagnosticItem[];
|
||||
/** Visual surface chrome for the outer block */
|
||||
surface?: 'raised' | 'flat';
|
||||
className?: string;
|
||||
|
|
@ -153,15 +156,20 @@ export const ProvisioningProgressBlock = ({
|
|||
pid,
|
||||
cliLogsTail,
|
||||
assistantOutput,
|
||||
launchDiagnostics,
|
||||
surface = 'raised',
|
||||
className,
|
||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||
const elapsed = useElapsedTimer(startedAt, loading);
|
||||
const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false);
|
||||
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
|
||||
const outputScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isError = tone === 'error';
|
||||
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
|
||||
const visibleLaunchDiagnostics =
|
||||
launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ??
|
||||
[];
|
||||
|
||||
// Auto-scroll assistant output
|
||||
useEffect(() => {
|
||||
|
|
@ -293,6 +301,42 @@ export const ProvisioningProgressBlock = ({
|
|||
errorIndex={errorStepIndex}
|
||||
/>
|
||||
</div>
|
||||
{visibleLaunchDiagnostics.length > 0 ? (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
className="flex items-center gap-1 text-[11px] text-[var(--color-text-muted)] hover:text-[var(--color-text-secondary)]"
|
||||
onClick={() => setDiagnosticsOpen((v) => !v)}
|
||||
>
|
||||
{diagnosticsOpen ? <ChevronDown size={12} /> : <ChevronRight size={12} />}
|
||||
Diagnostics
|
||||
</button>
|
||||
{diagnosticsOpen ? (
|
||||
<div className="mt-1 space-y-1 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
{visibleLaunchDiagnostics.map((item) => (
|
||||
<div key={item.id} className="text-[11px]">
|
||||
<div
|
||||
className={cn(
|
||||
item.severity === 'error'
|
||||
? 'text-red-400'
|
||||
: item.severity === 'warning'
|
||||
? 'text-amber-400'
|
||||
: 'text-[var(--color-text-secondary)]'
|
||||
)}
|
||||
>
|
||||
{item.label}
|
||||
</div>
|
||||
{item.detail ? (
|
||||
<div className="mt-0.5 text-[10px] text-[var(--color-text-muted)]">
|
||||
{item.detail}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
|
|||
|
|
@ -56,7 +56,6 @@ import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummar
|
|||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||
import { deriveContextMetrics } from '@shared/utils/contextMetrics';
|
||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import {
|
||||
AlertTriangle,
|
||||
|
|
@ -158,11 +157,6 @@ interface CreateTaskDialogState {
|
|||
defaultChip?: InlineChip;
|
||||
}
|
||||
|
||||
const logger = createLogger('Component:TeamDetailView');
|
||||
const TEAM_DETAIL_COMMIT_WARN_MS = 32;
|
||||
const TEAM_DETAIL_RENDER_BURST_WINDOW_MS = 4_000;
|
||||
const TEAM_DETAIL_RENDER_BURST_WARN_COUNT = 8;
|
||||
const TEAM_DETAIL_RENDER_WARN_THROTTLE_MS = 2_000;
|
||||
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
|
||||
|
||||
function areResolvedMembersEqual(
|
||||
|
|
@ -257,7 +251,7 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
|
|||
memberCount: team.memberCount,
|
||||
expectedMemberCount: team.expectedMemberCount,
|
||||
confirmedCount: team.confirmedCount,
|
||||
runtimeAlivePendingCount: team.runtimeAlivePendingCount,
|
||||
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
|
||||
teamLaunchState: team.teamLaunchState,
|
||||
partialLaunchFailure: team.partialLaunchFailure,
|
||||
missingMemberCount: team.missingMembers?.length ?? 0,
|
||||
|
|
@ -267,12 +261,12 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
|
|||
|
||||
const message =
|
||||
summary?.teamLaunchState === 'partial_pending'
|
||||
? summary.runtimeAlivePendingCount != null && summary.runtimeAlivePendingCount > 0
|
||||
? summary.runtimeProcessPendingCount != null && summary.runtimeProcessPendingCount > 0
|
||||
? buildPendingRuntimeSummaryCopy({
|
||||
confirmedCount: summary.confirmedCount,
|
||||
expectedMemberCount: summary.expectedMemberCount,
|
||||
memberCount: summary.memberCount,
|
||||
runtimeAlivePendingCount: summary.runtimeAlivePendingCount,
|
||||
runtimeProcessPendingCount: summary.runtimeProcessPendingCount,
|
||||
})
|
||||
: 'Last launch is still reconciling'
|
||||
: summary?.partialLaunchFailure
|
||||
|
|
@ -809,6 +803,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
|||
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
|
||||
[runtimeSnapshot?.members]
|
||||
);
|
||||
const runtimeRunId = runtimeSnapshot?.runId ?? memberSpawnSnapshot?.runId ?? progress?.runId;
|
||||
const isLaunchSettling = useMemo(() => {
|
||||
if (progress?.state !== 'ready') {
|
||||
return false;
|
||||
|
|
@ -828,6 +823,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
|||
leadActivity={leadActivity}
|
||||
memberSpawnStatuses={memberSpawnStatusMap}
|
||||
memberRuntimeEntries={memberRuntimeMap}
|
||||
runtimeRunId={runtimeRunId}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
/>
|
||||
);
|
||||
|
|
@ -889,6 +885,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
spawnEntry,
|
||||
runtimeRunId,
|
||||
runtimeEntry,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
|
|
@ -899,6 +896,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
||||
runtimeRunId:
|
||||
s.teamAgentRuntimeByTeam[teamName]?.runId ??
|
||||
s.memberSpawnSnapshotsByTeam[teamName]?.runId ??
|
||||
getCurrentProvisioningProgressForTeam(s, teamName)?.runId,
|
||||
runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined,
|
||||
}))
|
||||
);
|
||||
|
|
@ -924,6 +925,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
leadActivity={leadActivity}
|
||||
spawnEntry={spawnEntry}
|
||||
runtimeEntry={runtimeEntry}
|
||||
runtimeRunId={runtimeRunId}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
@ -932,13 +934,6 @@ export const TeamDetailView = ({
|
|||
teamName,
|
||||
isPaneFocused = false,
|
||||
}: TeamDetailViewProps): React.JSX.Element => {
|
||||
const renderStartedAtRef = useRef(performance.now());
|
||||
const renderDiagnosticsRef = useRef({
|
||||
windowStartedAt: Date.now(),
|
||||
count: 0,
|
||||
lastWarnAt: 0,
|
||||
});
|
||||
renderStartedAtRef.current = performance.now();
|
||||
const { isLight } = useTheme();
|
||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
||||
|
|
@ -1240,6 +1235,7 @@ export const TeamDetailView = ({
|
|||
reviewActionError,
|
||||
addMember,
|
||||
restartMember,
|
||||
skipMemberForLaunch,
|
||||
removeMember,
|
||||
updateMemberRole,
|
||||
launchTeam,
|
||||
|
|
@ -1290,6 +1286,7 @@ export const TeamDetailView = ({
|
|||
reviewActionError: s.reviewActionError,
|
||||
addMember: s.addMember,
|
||||
restartMember: s.restartMember,
|
||||
skipMemberForLaunch: s.skipMemberForLaunch,
|
||||
removeMember: s.removeMember,
|
||||
updateMemberRole: s.updateMemberRole,
|
||||
launchTeam: s.launchTeam,
|
||||
|
|
@ -1328,38 +1325,6 @@ export const TeamDetailView = ({
|
|||
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
||||
const wasInteractiveRef = useRef(false);
|
||||
|
||||
useEffect(() => {
|
||||
const now = Date.now();
|
||||
const diagnostic = renderDiagnosticsRef.current;
|
||||
if (now - diagnostic.windowStartedAt > TEAM_DETAIL_RENDER_BURST_WINDOW_MS) {
|
||||
diagnostic.windowStartedAt = now;
|
||||
diagnostic.count = 0;
|
||||
}
|
||||
diagnostic.count += 1;
|
||||
|
||||
const commitMs = performance.now() - renderStartedAtRef.current;
|
||||
const tasksCount = data?.tasks.length ?? 0;
|
||||
const membersCount = members.length;
|
||||
const processesCount = data?.processes.length ?? 0;
|
||||
const shouldWarnSlow = commitMs >= TEAM_DETAIL_COMMIT_WARN_MS;
|
||||
const shouldWarnBurst = diagnostic.count >= TEAM_DETAIL_RENDER_BURST_WARN_COUNT;
|
||||
const shouldWarnLarge = tasksCount >= 80;
|
||||
|
||||
if (
|
||||
(shouldWarnSlow || shouldWarnBurst || shouldWarnLarge) &&
|
||||
now - diagnostic.lastWarnAt >= TEAM_DETAIL_RENDER_WARN_THROTTLE_MS
|
||||
) {
|
||||
diagnostic.lastWarnAt = now;
|
||||
logger.warn(
|
||||
`[perf] commit team=${teamName} ms=${commitMs.toFixed(1)} renders=${diagnostic.count} windowMs=${
|
||||
now - diagnostic.windowStartedAt
|
||||
} activeTab=${isThisTabActive ? 'yes' : 'no'} paneFocused=${isPaneFocused ? 'yes' : 'no'} loading=${
|
||||
loading ? 'yes' : 'no'
|
||||
} tasks=${tasksCount} members=${membersCount} processes=${processesCount} panel=${messagesPanelMode}`
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// Messages panel resize
|
||||
const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } =
|
||||
useResizablePanel({
|
||||
|
|
@ -1795,6 +1760,20 @@ export const TeamDetailView = ({
|
|||
openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch');
|
||||
}, [data?.isAlive, isTeamProvisioning, openLaunchDialog]);
|
||||
|
||||
const handleRestartMember = useCallback(
|
||||
async (memberName: string): Promise<void> => {
|
||||
await restartMember(teamName, memberName);
|
||||
},
|
||||
[restartMember, teamName]
|
||||
);
|
||||
|
||||
const handleSkipMemberForLaunch = useCallback(
|
||||
async (memberName: string): Promise<void> => {
|
||||
await skipMemberForLaunch(teamName, memberName);
|
||||
},
|
||||
[skipMemberForLaunch, teamName]
|
||||
);
|
||||
|
||||
const handleSelectMember = useCallback((member: ResolvedTeamMember) => {
|
||||
setSelectedMember(member);
|
||||
setSelectedMemberView(null);
|
||||
|
|
@ -2484,6 +2463,8 @@ export const TeamDetailView = ({
|
|||
onSendMessage={handleSendMessageToMember}
|
||||
onAssignTask={handleAssignTaskToMember}
|
||||
onOpenTask={handleOpenTaskById}
|
||||
onRestartMember={handleRestartMember}
|
||||
onSkipMemberForLaunch={handleSkipMemberForLaunch}
|
||||
/>
|
||||
</CollapsibleTeamSection>
|
||||
|
||||
|
|
@ -2783,7 +2764,7 @@ export const TeamDetailView = ({
|
|||
closeSelectedMemberDialog();
|
||||
openCreateTaskDialog('', '', name);
|
||||
}}
|
||||
onRestartMember={(memberName) => restartMember(teamName, memberName)}
|
||||
onRestartMember={handleRestartMember}
|
||||
onTaskClick={(task) => {
|
||||
closeSelectedMemberDialog();
|
||||
setSelectedTask(task);
|
||||
|
|
|
|||
|
|
@ -82,6 +82,7 @@ type TeamStatus =
|
|||
| 'provisioning'
|
||||
| 'offline'
|
||||
| 'partial_failure'
|
||||
| 'partial_skipped'
|
||||
| 'partial_pending';
|
||||
|
||||
function getRecentProjects(team: TeamSummary): string[] {
|
||||
|
|
@ -206,6 +207,9 @@ function resolveTeamStatus(
|
|||
if (team.teamLaunchState === 'partial_pending') {
|
||||
return 'partial_pending';
|
||||
}
|
||||
if (team.teamLaunchState === 'partial_skipped') {
|
||||
return 'partial_skipped';
|
||||
}
|
||||
if (team.partialLaunchFailure || team.teamLaunchState === 'partial_failure') {
|
||||
return 'partial_failure';
|
||||
}
|
||||
|
|
@ -249,6 +253,13 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
|||
Launch failed partway
|
||||
</span>
|
||||
);
|
||||
case 'partial_skipped':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-sky-500/15 px-2 py-0.5 text-[10px] font-medium text-sky-300">
|
||||
<span className="size-1.5 rounded-full bg-sky-300" />
|
||||
Launch skipped member
|
||||
</span>
|
||||
);
|
||||
case 'partial_pending':
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full bg-amber-500/15 px-2 py-0.5 text-[10px] font-medium text-amber-300">
|
||||
|
|
@ -905,6 +916,7 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
<div className="flex shrink-0 gap-1">
|
||||
{(status === 'offline' ||
|
||||
status === 'partial_failure' ||
|
||||
status === 'partial_skipped' ||
|
||||
status === 'partial_pending') &&
|
||||
team.projectPath && (
|
||||
<Tooltip>
|
||||
|
|
@ -981,12 +993,12 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
</div>
|
||||
{team.teamLaunchState === 'partial_pending' ? (
|
||||
<p className="mt-2 text-[11px] text-amber-300">
|
||||
{team.runtimeAlivePendingCount && team.runtimeAlivePendingCount > 0
|
||||
{team.runtimeProcessPendingCount && team.runtimeProcessPendingCount > 0
|
||||
? buildPendingRuntimeSummaryCopy({
|
||||
confirmedCount: team.confirmedCount,
|
||||
expectedMemberCount: team.expectedMemberCount,
|
||||
memberCount: team.memberCount,
|
||||
runtimeAlivePendingCount: team.runtimeAlivePendingCount,
|
||||
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
|
||||
includePeriod: true,
|
||||
})
|
||||
: 'Last launch is still reconciling.'}
|
||||
|
|
@ -997,6 +1009,12 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
? `Last launch stopped before ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.`
|
||||
: 'Last launch stopped before all teammates joined.'}
|
||||
</p>
|
||||
) : team.teamLaunchState === 'partial_skipped' ? (
|
||||
<p className="mt-2 text-[11px] text-sky-300">
|
||||
{team.skippedMembers?.length
|
||||
? `Last launch skipped ${team.skippedMembers.length}/${team.expectedMemberCount ?? team.skippedMembers.length} teammate${team.skippedMembers.length === 1 ? '' : 's'}.`
|
||||
: 'Last launch has skipped teammates.'}
|
||||
</p>
|
||||
) : null}
|
||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||
{team.members && team.members.length > 0 ? (
|
||||
|
|
|
|||
|
|
@ -62,6 +62,7 @@ export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({
|
|||
pid={presentation.progress.pid}
|
||||
cliLogsTail={presentation.progress.cliLogsTail}
|
||||
assistantOutput={presentation.progress.assistantOutput}
|
||||
launchDiagnostics={presentation.progress.launchDiagnostics}
|
||||
defaultLiveOutputOpen={presentation.defaultLiveOutputOpen}
|
||||
defaultLogsOpen={defaultLogsOpen}
|
||||
onCancel={
|
||||
|
|
|
|||
|
|
@ -87,6 +87,8 @@ import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils
|
|||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
|
||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
||||
import { CodexFastModeSelector } from './CodexFastModeSelector';
|
||||
import {
|
||||
clearInheritedMemberModelsUnavailableForProvider,
|
||||
resolveProviderScopedMemberModel,
|
||||
|
|
@ -1423,10 +1425,12 @@ export const CreateTeamDialog = ({
|
|||
const summary: string[] = [];
|
||||
if (prompt.trim()) summary.push('Lead prompt');
|
||||
if (skipPermissions) summary.push('Auto-approve tools');
|
||||
if (selectedProviderId === 'anthropic') {
|
||||
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
|
||||
if (selectedFastMode === 'on') summary.push('Fast mode');
|
||||
else if (selectedFastMode === 'off') summary.push('Fast disabled');
|
||||
else if (anthropicProviderFastModeDefault) summary.push('Fast default');
|
||||
else if (selectedProviderId === 'anthropic' && anthropicProviderFastModeDefault) {
|
||||
summary.push('Fast default');
|
||||
}
|
||||
}
|
||||
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
||||
if (customArgs.trim()) summary.push('Custom CLI args');
|
||||
|
|
@ -1723,9 +1727,6 @@ export const CreateTeamDialog = ({
|
|||
onProviderChange={setSelectedProviderId}
|
||||
onModelChange={setSelectedModel}
|
||||
onEffortChange={setSelectedEffort}
|
||||
fastMode={selectedFastMode}
|
||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
||||
onFastModeChange={setSelectedFastMode}
|
||||
onLimitContextChange={setLimitContext}
|
||||
syncModelsWithTeammates={syncModelsWithLead}
|
||||
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
||||
|
|
@ -1734,7 +1735,6 @@ export const CreateTeamDialog = ({
|
|||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
disableGeminiOption={isGeminiUiFrozen()}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
leadFastModeNotice={anthropicRuntimeNotice}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
headerTop={
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -1820,6 +1820,47 @@ export const CreateTeamDialog = ({
|
|||
summary={launchOptionalSummary}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{selectedProviderId === 'anthropic' ? (
|
||||
<div className="space-y-2">
|
||||
<AnthropicFastModeSelector
|
||||
value={selectedFastMode}
|
||||
onValueChange={setSelectedFastMode}
|
||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
||||
model={selectedModel}
|
||||
limitContext={limitContext}
|
||||
id="create-fast-mode"
|
||||
/>
|
||||
{anthropicRuntimeNotice ? (
|
||||
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||
<p>{anthropicRuntimeNotice}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedProviderId === 'codex' ? (
|
||||
<div className="space-y-2">
|
||||
<CodexFastModeSelector
|
||||
value={selectedFastMode}
|
||||
onValueChange={setSelectedFastMode}
|
||||
model={selectedModel}
|
||||
providerBackendId={
|
||||
resolveUiOwnedProviderBackendId(
|
||||
'codex',
|
||||
runtimeProviderStatusById.get('codex')
|
||||
) ?? undefined
|
||||
}
|
||||
id="create-fast-mode"
|
||||
/>
|
||||
{anthropicRuntimeNotice ? (
|
||||
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||
<p>{anthropicRuntimeNotice}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="team-prompt" className="label-optional">
|
||||
Prompt for team lead (optional)
|
||||
|
|
|
|||
|
|
@ -80,6 +80,7 @@ import {
|
|||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
X,
|
||||
|
|
@ -1633,10 +1634,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
|
||||
if (selectedModel) summary.push(`Model: ${selectedModel}`);
|
||||
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
|
||||
if (selectedProviderId === 'anthropic') {
|
||||
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
|
||||
if (selectedFastMode === 'on') summary.push('Fast mode');
|
||||
else if (selectedFastMode === 'off') summary.push('Fast disabled');
|
||||
else if (anthropicProviderFastModeDefault) summary.push('Fast default');
|
||||
else if (selectedProviderId === 'anthropic' && anthropicProviderFastModeDefault) {
|
||||
summary.push('Fast default');
|
||||
}
|
||||
}
|
||||
if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context');
|
||||
if (skipPermissions) summary.push('Auto-approve tools');
|
||||
|
|
@ -2278,6 +2281,52 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
summary={launchOptionalSummary}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{selectedProviderId === 'anthropic' ? (
|
||||
<div className="space-y-2">
|
||||
<AnthropicFastModeSelector
|
||||
value={selectedFastMode}
|
||||
onValueChange={setSelectedFastMode}
|
||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
||||
model={selectedModel}
|
||||
limitContext={effectiveAnthropicRuntimeLimitContext}
|
||||
id="launch-fast-mode"
|
||||
/>
|
||||
{anthropicRuntimeNotice ? (
|
||||
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||
<p>{anthropicRuntimeNotice}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedProviderId === 'codex' ? (
|
||||
<div className="space-y-2">
|
||||
<CodexFastModeSelector
|
||||
value={selectedFastMode}
|
||||
onValueChange={setSelectedFastMode}
|
||||
model={selectedModel}
|
||||
providerBackendId={
|
||||
resolveUiOwnedProviderBackendId(
|
||||
'codex',
|
||||
runtimeProviderStatusById.get('codex')
|
||||
) ??
|
||||
migrateProviderBackendId(
|
||||
'codex',
|
||||
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
|
||||
) ??
|
||||
undefined
|
||||
}
|
||||
id="launch-fast-mode"
|
||||
/>
|
||||
{anthropicRuntimeNotice ? (
|
||||
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||
<p>{anthropicRuntimeNotice}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TeamRosterEditorSection
|
||||
members={membersDrafts}
|
||||
onMembersChange={setMembersDrafts}
|
||||
|
|
@ -2300,13 +2349,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
providerId={selectedProviderId}
|
||||
model={selectedModel}
|
||||
effort={(selectedEffort as EffortLevel) || undefined}
|
||||
fastMode={selectedFastMode}
|
||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
||||
limitContext={limitContext}
|
||||
onProviderChange={setSelectedProviderId}
|
||||
onModelChange={setSelectedModel}
|
||||
onEffortChange={setSelectedEffort}
|
||||
onFastModeChange={setSelectedFastMode}
|
||||
onLimitContextChange={setLimitContext}
|
||||
syncModelsWithTeammates={syncModelsWithLead}
|
||||
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||
|
|
@ -2314,7 +2360,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
leadWarningText={leadRuntimeWarningText}
|
||||
leadFastModeNotice={anthropicRuntimeNotice}
|
||||
memberWarningById={memberRuntimeWarningById}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
|
|
|
|||
|
|
@ -492,6 +492,20 @@ function resolveModelResultFromCompatibilityBatch(
|
|||
? (getResultReason(modelId, result) ?? normalizeModelReason(result.message))
|
||||
: null;
|
||||
|
||||
const hasVerifiedLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* verified for launch\./i.test(entry)
|
||||
);
|
||||
if (hasVerifiedLine) {
|
||||
return {
|
||||
kind: 'terminal',
|
||||
result: {
|
||||
status: 'ready',
|
||||
line: buildModelSuccessLine(providerId, modelId),
|
||||
warningLine: null,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const hasCompatibilityLine = modelScopedEntries.some((entry) =>
|
||||
/selected model .* is compatible\. deep verification pending\./i.test(entry)
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { AnthropicFastModeSelector } from '@renderer/components/team/dialogs/AnthropicFastModeSelector';
|
||||
import { CodexFastModeSelector } from '@renderer/components/team/dialogs/CodexFastModeSelector';
|
||||
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
||||
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
|
||||
import {
|
||||
|
|
@ -24,24 +22,20 @@ import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
|||
|
||||
import { Button } from '../../ui/button';
|
||||
|
||||
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
interface LeadModelRowProps {
|
||||
providerId: TeamProviderId;
|
||||
model: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
providerFastModeDefault?: boolean;
|
||||
limitContext: boolean;
|
||||
onProviderChange: (providerId: TeamProviderId) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onEffortChange: (effort: string) => void;
|
||||
onFastModeChange?: (fastMode: TeamFastMode) => void;
|
||||
onLimitContextChange: (value: boolean) => void;
|
||||
syncModelsWithTeammates: boolean;
|
||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||
warningText?: string | null;
|
||||
fastModeNotice?: string | null;
|
||||
disableGeminiOption?: boolean;
|
||||
modelIssueText?: string | null;
|
||||
}
|
||||
|
|
@ -50,18 +44,14 @@ export const LeadModelRow = ({
|
|||
providerId,
|
||||
model,
|
||||
effort,
|
||||
fastMode = 'inherit',
|
||||
providerFastModeDefault = false,
|
||||
limitContext,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onEffortChange,
|
||||
onFastModeChange,
|
||||
onLimitContextChange,
|
||||
syncModelsWithTeammates,
|
||||
onSyncModelsWithTeammatesChange,
|
||||
warningText,
|
||||
fastModeNotice,
|
||||
disableGeminiOption = false,
|
||||
modelIssueText,
|
||||
}: LeadModelRowProps): React.JSX.Element => {
|
||||
|
|
@ -173,24 +163,6 @@ export const LeadModelRow = ({
|
|||
model={model}
|
||||
limitContext={limitContext}
|
||||
/>
|
||||
{providerId === 'anthropic' && onFastModeChange ? (
|
||||
<AnthropicFastModeSelector
|
||||
value={fastMode}
|
||||
onValueChange={onFastModeChange}
|
||||
providerFastModeDefault={providerFastModeDefault}
|
||||
model={model}
|
||||
limitContext={limitContext}
|
||||
id="lead-fast-mode"
|
||||
/>
|
||||
) : null}
|
||||
{providerId === 'codex' && onFastModeChange ? (
|
||||
<CodexFastModeSelector
|
||||
value={fastMode}
|
||||
onValueChange={onFastModeChange}
|
||||
model={model}
|
||||
id="lead-fast-mode"
|
||||
/>
|
||||
) : null}
|
||||
{providerId === 'anthropic' ? (
|
||||
<LimitContextCheckbox
|
||||
id="lead-limit-context"
|
||||
|
|
@ -199,12 +171,6 @@ export const LeadModelRow = ({
|
|||
disabled={isAnthropicHaikuTeamModel(model)}
|
||||
/>
|
||||
) : null}
|
||||
{fastModeNotice ? (
|
||||
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||
<p>{fastModeNotice}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { useMemo } from 'react';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import { Badge } from '@renderer/components/ui/badge';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
|
|
@ -13,10 +13,26 @@ import {
|
|||
buildMemberLaunchPresentation,
|
||||
displayMemberName,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
buildMemberLaunchDiagnosticsPayload,
|
||||
hasMemberLaunchDiagnosticsDetails,
|
||||
hasMemberLaunchDiagnosticsError,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
AlertTriangle,
|
||||
Ban,
|
||||
GitBranch,
|
||||
Loader2,
|
||||
MessageSquare,
|
||||
Plus,
|
||||
RotateCcw,
|
||||
} from 'lucide-react';
|
||||
|
||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
|
||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type {
|
||||
|
|
@ -24,7 +40,9 @@ import type {
|
|||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
MemberSpawnStatusEntry,
|
||||
ResolvedTeamMember,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamTaskWithKanban,
|
||||
} from '@shared/types';
|
||||
|
||||
|
|
@ -32,6 +50,8 @@ interface MemberCardProps {
|
|||
member: ResolvedTeamMember;
|
||||
memberColor: string;
|
||||
runtimeSummary?: string;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
runtimeRunId?: string | null;
|
||||
taskCounts?: TaskStatusCounts | null;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
|
|
@ -41,6 +61,7 @@ interface MemberCardProps {
|
|||
isAwaitingReply?: boolean;
|
||||
isRemoved?: boolean;
|
||||
spawnStatus?: MemberSpawnStatus;
|
||||
spawnEntry?: MemberSpawnStatusEntry;
|
||||
spawnError?: string;
|
||||
spawnLivenessSource?: MemberSpawnLivenessSource;
|
||||
spawnLaunchState?: MemberLaunchState;
|
||||
|
|
@ -51,6 +72,8 @@ interface MemberCardProps {
|
|||
onClick?: () => void;
|
||||
onSendMessage?: () => void;
|
||||
onAssignTask?: () => void;
|
||||
onRestartMember?: (memberName: string) => Promise<void> | void;
|
||||
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
||||
|
|
@ -77,6 +100,8 @@ export const MemberCard = ({
|
|||
member,
|
||||
memberColor,
|
||||
runtimeSummary,
|
||||
runtimeEntry,
|
||||
runtimeRunId,
|
||||
taskCounts,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
|
|
@ -86,6 +111,7 @@ export const MemberCard = ({
|
|||
isAwaitingReply,
|
||||
isRemoved,
|
||||
spawnStatus,
|
||||
spawnEntry,
|
||||
spawnError,
|
||||
spawnLivenessSource,
|
||||
spawnLaunchState,
|
||||
|
|
@ -96,6 +122,8 @@ export const MemberCard = ({
|
|||
onClick,
|
||||
onSendMessage,
|
||||
onAssignTask,
|
||||
onRestartMember,
|
||||
onSkipMemberForLaunch,
|
||||
}: MemberCardProps): React.JSX.Element => {
|
||||
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||
// const teamName = useStore((s) => s.selectedTeamName);
|
||||
|
|
@ -103,6 +131,10 @@ export const MemberCard = ({
|
|||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||
// );
|
||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
||||
const [retryingLaunch, setRetryingLaunch] = useState(false);
|
||||
const [retryLaunchError, setRetryLaunchError] = useState<string | null>(null);
|
||||
const [skippingLaunch, setSkippingLaunch] = useState(false);
|
||||
const [skipLaunchError, setSkipLaunchError] = useState<string | null>(null);
|
||||
const teamMembers = useStore((s) =>
|
||||
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
||||
);
|
||||
|
|
@ -113,6 +145,7 @@ export const MemberCard = ({
|
|||
spawnLaunchState,
|
||||
spawnLivenessSource,
|
||||
spawnRuntimeAlive,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
isTeamAlive,
|
||||
|
|
@ -128,7 +161,12 @@ export const MemberCard = ({
|
|||
const launchVisualState = launchPresentation.launchVisualState;
|
||||
const launchStatusLabel = launchPresentation.launchStatusLabel;
|
||||
const displayPresenceLabel =
|
||||
launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending'
|
||||
launchVisualState === 'runtime_pending' ||
|
||||
launchVisualState === 'permission_pending' ||
|
||||
launchVisualState === 'shell_only' ||
|
||||
launchVisualState === 'runtime_candidate' ||
|
||||
launchVisualState === 'registered_only' ||
|
||||
launchVisualState === 'stale_runtime'
|
||||
? (launchStatusLabel ?? presenceLabel)
|
||||
: presenceLabel;
|
||||
const colors = getTeamColorSet(memberColor);
|
||||
|
|
@ -141,6 +179,7 @@ export const MemberCard = ({
|
|||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||
const { summary: runtimeSummaryText, memory: memoryLabel } =
|
||||
splitRuntimeSummaryMemory(runtimeSummary);
|
||||
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
||||
const activityTask = currentTask ?? reviewTask ?? null;
|
||||
const activityTitle = currentTask
|
||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||
|
|
@ -159,14 +198,101 @@ export const MemberCard = ({
|
|||
!runtimeAdvisoryLabel &&
|
||||
(presenceLabel === 'starting' ||
|
||||
presenceLabel === 'connecting' ||
|
||||
launchVisualState === 'runtime_pending');
|
||||
launchVisualState === 'runtime_pending' ||
|
||||
launchVisualState === 'shell_only' ||
|
||||
launchVisualState === 'runtime_candidate' ||
|
||||
launchVisualState === 'registered_only' ||
|
||||
launchVisualState === 'stale_runtime');
|
||||
const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel;
|
||||
const launchDiagnosticsPayload = useMemo(
|
||||
() =>
|
||||
buildMemberLaunchDiagnosticsPayload({
|
||||
teamName: selectedTeamName,
|
||||
runId: runtimeRunId,
|
||||
memberName: member.name,
|
||||
spawnStatus,
|
||||
launchState: spawnLaunchState,
|
||||
livenessSource: spawnLivenessSource,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
}),
|
||||
[
|
||||
member.name,
|
||||
runtimeEntry,
|
||||
runtimeRunId,
|
||||
selectedTeamName,
|
||||
spawnEntry,
|
||||
spawnLaunchState,
|
||||
spawnLivenessSource,
|
||||
spawnStatus,
|
||||
]
|
||||
);
|
||||
const showCopyDiagnostics =
|
||||
!isRemoved &&
|
||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||
const isFailedLaunch = spawnStatus === 'error' || spawnLaunchState === 'failed_to_start';
|
||||
const isSkippedLaunch =
|
||||
spawnStatus === 'skipped' ||
|
||||
spawnLaunchState === 'skipped_for_launch' ||
|
||||
spawnEntry?.skippedForLaunch === true;
|
||||
const showFailedLaunchBadge = !isRemoved && isFailedLaunch;
|
||||
const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch;
|
||||
const hasLiveLaunchControls =
|
||||
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
|
||||
const canRetryLaunch =
|
||||
(showFailedLaunchBadge || showSkippedLaunchBadge) &&
|
||||
!isLeadMember(member) &&
|
||||
Boolean(onRestartMember) &&
|
||||
hasLiveLaunchControls;
|
||||
const canSkipFailedLaunch =
|
||||
showFailedLaunchBadge &&
|
||||
!isLeadMember(member) &&
|
||||
Boolean(onSkipMemberForLaunch) &&
|
||||
hasLiveLaunchControls;
|
||||
const showRuntimeAdvisoryBadge =
|
||||
!isRemoved &&
|
||||
Boolean(runtimeAdvisoryLabel) &&
|
||||
!showLaunchBadge &&
|
||||
spawnStatus !== 'error' &&
|
||||
!isFailedLaunch &&
|
||||
!isSkippedLaunch &&
|
||||
(Boolean(activityTask) || !isAwaitingReply);
|
||||
const handleRetryFailedLaunch = async (
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
): Promise<void> => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!onRestartMember || retryingLaunch) {
|
||||
return;
|
||||
}
|
||||
setRetryLaunchError(null);
|
||||
setRetryingLaunch(true);
|
||||
try {
|
||||
await onRestartMember(member.name);
|
||||
} catch (error) {
|
||||
setRetryLaunchError(error instanceof Error ? error.message : 'Failed to retry teammate');
|
||||
} finally {
|
||||
setRetryingLaunch(false);
|
||||
}
|
||||
};
|
||||
const handleSkipFailedLaunch = async (
|
||||
event: React.MouseEvent<HTMLButtonElement>
|
||||
): Promise<void> => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!onSkipMemberForLaunch || skippingLaunch) {
|
||||
return;
|
||||
}
|
||||
setSkipLaunchError(null);
|
||||
setSkippingLaunch(true);
|
||||
try {
|
||||
await onSkipMemberForLaunch(member.name);
|
||||
} catch (error) {
|
||||
setSkipLaunchError(error instanceof Error ? error.message : 'Failed to skip teammate');
|
||||
} finally {
|
||||
setSkippingLaunch(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
|
|
@ -283,12 +409,19 @@ export const MemberCard = ({
|
|||
{(runtimeSummaryText || roleLabel) && memoryLabel ? (
|
||||
<span className="shrink-0 opacity-60">•</span>
|
||||
) : null}
|
||||
{memoryLabel ? <span className="shrink-0">{memoryLabel}</span> : null}
|
||||
{memoryLabel ? (
|
||||
<span className="shrink-0" title={memorySourceLabel}>
|
||||
{memoryLabel}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
{showLaunchBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<span
|
||||
className="flex shrink-0 items-center gap-1"
|
||||
title={runtimeEntry?.runtimeDiagnostic}
|
||||
>
|
||||
<Loader2
|
||||
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
|
||||
aria-label={launchBadgeLabel}
|
||||
|
|
@ -300,7 +433,8 @@ export const MemberCard = ({
|
|||
{launchBadgeLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
) : spawnStatus === 'error' ? (
|
||||
) : showFailedLaunchBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
|
|
@ -315,6 +449,101 @@ export const MemberCard = ({
|
|||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showCopyDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
|
||||
/>
|
||||
) : null}
|
||||
{canSkipFailedLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={skippingLaunch ? 'Skipping teammate' : 'Skip for this launch'}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={skippingLaunch || retryingLaunch}
|
||||
onClick={handleSkipFailedLaunch}
|
||||
>
|
||||
{skippingLaunch ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<Ban className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{skipLaunchError ??
|
||||
(skippingLaunch ? 'Skipping teammate...' : 'Skip for this launch')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
{canRetryLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={retryingLaunch ? 'Retrying teammate' : 'Retry teammate'}
|
||||
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch || skippingLaunch}
|
||||
onClick={handleRetryFailedLaunch}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : showSkippedLaunchBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Ban className="size-3.5 shrink-0 text-zinc-400" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 bg-zinc-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-zinc-300"
|
||||
>
|
||||
{displayPresenceLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{spawnEntry?.skipReason ?? 'Skipped for this launch'}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
{canRetryLaunch ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={retryingLaunch ? 'Retrying teammate' : 'Retry teammate'}
|
||||
className="rounded p-1 text-zinc-300 transition-colors hover:bg-zinc-500/10 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
|
||||
disabled={retryingLaunch}
|
||||
onClick={handleRetryFailedLaunch}
|
||||
>
|
||||
{retryingLaunch ? (
|
||||
<Loader2 className="size-3.5 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="size-3.5" />
|
||||
)}
|
||||
</button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">
|
||||
{retryLaunchError ??
|
||||
(retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')}
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
) : null}
|
||||
</span>
|
||||
) : showRuntimeAdvisoryBadge ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
|
|||
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
||||
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||
import {
|
||||
buildMemberLaunchDiagnosticsPayload,
|
||||
getMemberLaunchDiagnosticsErrorMessage,
|
||||
hasMemberLaunchDiagnosticsDetails,
|
||||
hasMemberLaunchDiagnosticsError,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import {
|
||||
getRuntimeMemorySourceLabel,
|
||||
resolveMemberRuntimeSummary,
|
||||
} from '@renderer/utils/memberRuntimeSummary';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
BarChart3,
|
||||
|
|
@ -22,6 +31,7 @@ import { buildMemberActivityEntries } from './memberActivityEntries';
|
|||
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||
import { MemberDetailStats } from './MemberDetailStats';
|
||||
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
import { MemberLogsTab } from './MemberLogsTab';
|
||||
import { MemberMessagesTab } from './MemberMessagesTab';
|
||||
import { MemberStatsTab } from './MemberStatsTab';
|
||||
|
|
@ -50,6 +60,7 @@ interface MemberDetailDialogProps {
|
|||
leadActivity?: LeadActivityState;
|
||||
spawnEntry?: MemberSpawnStatusEntry;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
runtimeRunId?: string | null;
|
||||
launchParams?: TeamLaunchParams;
|
||||
onClose: () => void;
|
||||
onSendMessage: () => void;
|
||||
|
|
@ -76,6 +87,7 @@ export const MemberDetailDialog = ({
|
|||
leadActivity,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
runtimeRunId,
|
||||
launchParams,
|
||||
onClose,
|
||||
onSendMessage,
|
||||
|
|
@ -128,10 +140,31 @@ export const MemberDetailDialog = ({
|
|||
: undefined,
|
||||
[launchParams, member, runtimeEntry, spawnEntry]
|
||||
);
|
||||
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
||||
const restartInFlight =
|
||||
spawnEntry?.launchState === 'starting' ||
|
||||
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
|
||||
spawnEntry?.launchState === 'runtime_pending_permission';
|
||||
const launchDiagnosticsPayload = useMemo(
|
||||
() =>
|
||||
member
|
||||
? buildMemberLaunchDiagnosticsPayload({
|
||||
teamName,
|
||||
runId: runtimeRunId,
|
||||
memberName: member.name,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
})
|
||||
: null,
|
||||
[member, runtimeEntry, runtimeRunId, spawnEntry, teamName]
|
||||
);
|
||||
const showCopyDiagnostics =
|
||||
launchDiagnosticsPayload != null &&
|
||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||
const launchErrorMessage = launchDiagnosticsPayload
|
||||
? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload)
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !member) {
|
||||
|
|
@ -167,6 +200,7 @@ export const MemberDetailDialog = ({
|
|||
spawnLaunchState={spawnEntry?.launchState}
|
||||
spawnLivenessSource={spawnEntry?.livenessSource}
|
||||
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
||||
runtimeEntry={runtimeEntry}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
onUpdateRole={
|
||||
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
||||
|
|
@ -250,9 +284,23 @@ export const MemberDetailDialog = ({
|
|||
<DialogFooter>
|
||||
{restartError ? (
|
||||
<div className="mr-auto text-xs text-red-400">{restartError}</div>
|
||||
) : launchErrorMessage ? (
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2 text-xs text-red-400">
|
||||
<span className="min-w-0 truncate" title={launchErrorMessage}>
|
||||
{launchErrorMessage}
|
||||
</span>
|
||||
{launchDiagnosticsPayload && showCopyDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
label="Copy diagnostics"
|
||||
className="h-auto shrink-0 gap-1.5 px-2 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : runtimeEntry?.pid ? (
|
||||
<div className="mr-auto text-xs text-[var(--color-text-muted)]">
|
||||
PID {runtimeEntry.pid}
|
||||
{memorySourceLabel ? ` · ${memorySourceLabel}` : ''}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mr-auto" />
|
||||
|
|
|
|||
|
|
@ -23,11 +23,13 @@ import type {
|
|||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamAgentRuntimeEntry,
|
||||
} from '@shared/types';
|
||||
|
||||
interface MemberDetailHeaderProps {
|
||||
member: ResolvedTeamMember;
|
||||
runtimeSummary?: string;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
leadActivity?: LeadActivityState;
|
||||
|
|
@ -43,6 +45,7 @@ interface MemberDetailHeaderProps {
|
|||
export const MemberDetailHeader = ({
|
||||
member,
|
||||
runtimeSummary,
|
||||
runtimeEntry,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
|
|
@ -75,6 +78,7 @@ export const MemberDetailHeader = ({
|
|||
spawnLaunchState,
|
||||
spawnLivenessSource,
|
||||
spawnRuntimeAlive,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
isTeamAlive,
|
||||
|
|
@ -91,7 +95,12 @@ export const MemberDetailHeader = ({
|
|||
const badgeLabel =
|
||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||
? runtimeAdvisoryLabel
|
||||
: launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending'
|
||||
: launchVisualState === 'runtime_pending' ||
|
||||
launchVisualState === 'permission_pending' ||
|
||||
launchVisualState === 'shell_only' ||
|
||||
launchVisualState === 'runtime_candidate' ||
|
||||
launchVisualState === 'registered_only' ||
|
||||
launchVisualState === 'stale_runtime'
|
||||
? (launchStatusLabel ?? presenceLabel)
|
||||
: presenceLabel;
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ import {
|
|||
buildMemberLaunchPresentation,
|
||||
displayMemberName,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
buildMemberLaunchDiagnosticsPayload,
|
||||
getMemberLaunchDiagnosticsErrorMessage,
|
||||
hasMemberLaunchDiagnosticsDetails,
|
||||
hasMemberLaunchDiagnosticsError,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -29,6 +35,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
||||
|
||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
|
||||
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
@ -68,6 +75,8 @@ export const MemberHoverCard = ({
|
|||
memberSpawnSnapshot,
|
||||
memberSpawnStatuses,
|
||||
spawnEntry,
|
||||
runtimeRunId,
|
||||
runtimeEntry,
|
||||
leadActivity,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
|
|
@ -89,6 +98,12 @@ export const MemberHoverCard = ({
|
|||
spawnEntry: effectiveTeamName
|
||||
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
|
||||
: undefined,
|
||||
runtimeRunId: effectiveTeamName
|
||||
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.runId
|
||||
: undefined,
|
||||
runtimeEntry: effectiveTeamName
|
||||
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.members[name]
|
||||
: undefined,
|
||||
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
|
||||
}))
|
||||
);
|
||||
|
|
@ -114,6 +129,7 @@ export const MemberHoverCard = ({
|
|||
spawnLaunchState: spawnEntry?.launchState,
|
||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
isTeamAlive,
|
||||
|
|
@ -130,9 +146,25 @@ export const MemberHoverCard = ({
|
|||
const badgeLabel =
|
||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||
? runtimeAdvisoryLabel
|
||||
: launchVisualState === 'runtime_pending' || launchVisualState === 'permission_pending'
|
||||
: launchVisualState === 'runtime_pending' ||
|
||||
launchVisualState === 'permission_pending' ||
|
||||
launchVisualState === 'shell_only' ||
|
||||
launchVisualState === 'runtime_candidate' ||
|
||||
launchVisualState === 'registered_only' ||
|
||||
launchVisualState === 'stale_runtime'
|
||||
? (launchStatusLabel ?? presenceLabel)
|
||||
: presenceLabel;
|
||||
const launchDiagnosticsPayload = buildMemberLaunchDiagnosticsPayload({
|
||||
teamName: effectiveTeamName,
|
||||
runId: runtimeRunId ?? memberSpawnSnapshot?.runId ?? progress?.runId,
|
||||
memberName: member.name,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
});
|
||||
const launchErrorMessage = getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload);
|
||||
const showCopyDiagnostics =
|
||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
|
|
@ -226,10 +258,24 @@ export const MemberHoverCard = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Open profile button */}
|
||||
{launchErrorMessage ? (
|
||||
<div className="flex items-center gap-2 rounded border border-red-500/25 bg-red-500/10 px-2 py-1.5 text-xs text-red-300">
|
||||
<span className="min-w-0 flex-1 truncate" title={launchErrorMessage}>
|
||||
{launchErrorMessage}
|
||||
</span>
|
||||
{showCopyDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
className="h-auto shrink-0 rounded px-1.5 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMemberProfile(member.name);
|
||||
|
|
@ -239,6 +285,7 @@ export const MemberHoverCard = ({
|
|||
Open profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import {
|
||||
formatMemberLaunchDiagnosticsPayload,
|
||||
type MemberLaunchDiagnosticsPayload,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import { Check, ClipboardList } from 'lucide-react';
|
||||
|
||||
interface MemberLaunchDiagnosticsButtonProps {
|
||||
payload: MemberLaunchDiagnosticsPayload;
|
||||
label?: string;
|
||||
className?: string;
|
||||
size?: 'icon' | 'sm';
|
||||
}
|
||||
|
||||
export const MemberLaunchDiagnosticsButton = ({
|
||||
payload,
|
||||
label,
|
||||
className,
|
||||
size = label ? 'sm' : 'icon',
|
||||
}: MemberLaunchDiagnosticsButtonProps): React.JSX.Element => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyDiagnostics = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(formatMemberLaunchDiagnosticsPayload(payload));
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
const icon = copied ? <Check size={13} /> : <ClipboardList size={13} />;
|
||||
const tooltip = copied ? 'Diagnostics copied' : 'Copy diagnostics';
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={size}
|
||||
className={className}
|
||||
title={tooltip}
|
||||
aria-label={tooltip}
|
||||
onClick={copyDiagnostics}
|
||||
>
|
||||
{icon}
|
||||
{label ? <span>{label}</span> : null}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
|
@ -23,6 +23,7 @@ interface MemberListProps {
|
|||
pendingRepliesByMember?: Record<string, number>;
|
||||
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
|
||||
memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>;
|
||||
runtimeRunId?: string | null;
|
||||
isLaunchSettling?: boolean;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
|
|
@ -32,6 +33,8 @@ interface MemberListProps {
|
|||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||
onOpenTask?: (taskId: string) => void;
|
||||
onRestartMember?: (memberName: string) => Promise<void> | void;
|
||||
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
|
||||
}
|
||||
|
||||
function areResolvedMembersEquivalent(
|
||||
|
|
@ -150,7 +153,13 @@ function areMemberSpawnStatusesEquivalent(
|
|||
leftEntry.error !== rightEntry.error ||
|
||||
leftEntry.hardFailure !== rightEntry.hardFailure ||
|
||||
leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
|
||||
leftEntry.skippedForLaunch !== rightEntry.skippedForLaunch ||
|
||||
leftEntry.skipReason !== rightEntry.skipReason ||
|
||||
leftEntry.skippedAt !== rightEntry.skippedAt ||
|
||||
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
||||
leftEntry.livenessKind !== rightEntry.livenessKind ||
|
||||
leftEntry.runtimeDiagnostic !== rightEntry.runtimeDiagnostic ||
|
||||
leftEntry.runtimeDiagnosticSeverity !== rightEntry.runtimeDiagnosticSeverity ||
|
||||
leftEntry.runtimeModel !== rightEntry.runtimeModel ||
|
||||
leftEntry.runtimeAlive !== rightEntry.runtimeAlive ||
|
||||
leftEntry.bootstrapConfirmed !== rightEntry.bootstrapConfirmed ||
|
||||
|
|
@ -189,14 +198,34 @@ function areMemberRuntimeEntriesEquivalent(
|
|||
if (left.size !== right.size) return false;
|
||||
for (const [key, leftEntry] of left) {
|
||||
const rightEntry = right.get(key);
|
||||
const leftDiagnostics = leftEntry.diagnostics ?? [];
|
||||
const rightDiagnostics = rightEntry?.diagnostics ?? [];
|
||||
if (
|
||||
leftEntry.memberName !== rightEntry?.memberName ||
|
||||
leftEntry.alive !== rightEntry?.alive ||
|
||||
leftEntry.restartable !== rightEntry?.restartable ||
|
||||
leftEntry.backendType !== rightEntry?.backendType ||
|
||||
leftEntry.providerId !== rightEntry?.providerId ||
|
||||
leftEntry.providerBackendId !== rightEntry?.providerBackendId ||
|
||||
leftEntry.laneId !== rightEntry?.laneId ||
|
||||
leftEntry.laneKind !== rightEntry?.laneKind ||
|
||||
leftEntry.pid !== rightEntry?.pid ||
|
||||
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
|
||||
leftEntry.rssBytes !== rightEntry?.rssBytes
|
||||
leftEntry.rssBytes !== rightEntry?.rssBytes ||
|
||||
leftEntry.livenessKind !== rightEntry?.livenessKind ||
|
||||
leftEntry.pidSource !== rightEntry?.pidSource ||
|
||||
leftEntry.processCommand !== rightEntry?.processCommand ||
|
||||
leftEntry.paneId !== rightEntry?.paneId ||
|
||||
leftEntry.panePid !== rightEntry?.panePid ||
|
||||
leftEntry.paneCurrentCommand !== rightEntry?.paneCurrentCommand ||
|
||||
leftEntry.runtimePid !== rightEntry?.runtimePid ||
|
||||
leftEntry.runtimeSessionId !== rightEntry?.runtimeSessionId ||
|
||||
leftEntry.runtimeDiagnostic !== rightEntry?.runtimeDiagnostic ||
|
||||
leftEntry.runtimeDiagnosticSeverity !== rightEntry?.runtimeDiagnosticSeverity ||
|
||||
leftEntry.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt ||
|
||||
leftEntry.historicalBootstrapConfirmed !== rightEntry?.historicalBootstrapConfirmed ||
|
||||
leftDiagnostics.length !== rightDiagnostics.length ||
|
||||
!leftDiagnostics.every((value, index) => value === rightDiagnostics[index])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -215,10 +244,13 @@ function areMemberListPropsEqual(
|
|||
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
|
||||
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
|
||||
areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) &&
|
||||
prev.runtimeRunId === next.runtimeRunId &&
|
||||
prev.isLaunchSettling === next.isLaunchSettling &&
|
||||
prev.isTeamAlive === next.isTeamAlive &&
|
||||
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
||||
prev.leadActivity === next.leadActivity &&
|
||||
prev.onRestartMember === next.onRestartMember &&
|
||||
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
|
||||
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
|
||||
);
|
||||
}
|
||||
|
|
@ -230,6 +262,7 @@ export const MemberList = memo(function MemberList({
|
|||
pendingRepliesByMember,
|
||||
memberSpawnStatuses,
|
||||
memberRuntimeEntries,
|
||||
runtimeRunId,
|
||||
isLaunchSettling,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
|
|
@ -239,6 +272,8 @@ export const MemberList = memo(function MemberList({
|
|||
onSendMessage,
|
||||
onAssignTask,
|
||||
onOpenTask,
|
||||
onRestartMember,
|
||||
onSkipMemberForLaunch,
|
||||
}: MemberListProps): React.JSX.Element {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [isWide, setIsWide] = useState(false);
|
||||
|
|
@ -332,7 +367,10 @@ export const MemberList = memo(function MemberList({
|
|||
isRemoved ? undefined : spawnEntry,
|
||||
isRemoved ? undefined : runtimeEntry
|
||||
)}
|
||||
runtimeEntry={isRemoved ? undefined : runtimeEntry}
|
||||
runtimeRunId={isRemoved ? undefined : runtimeRunId}
|
||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||
spawnEntry={isRemoved ? undefined : spawnEntry}
|
||||
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
|
||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
||||
|
|
@ -343,6 +381,8 @@ export const MemberList = memo(function MemberList({
|
|||
onClick={() => onMemberClick?.(member)}
|
||||
onSendMessage={() => onSendMessage?.(member)}
|
||||
onAssignTask={() => onAssignTask?.(member)}
|
||||
onRestartMember={isRemoved ? undefined : onRestartMember}
|
||||
onSkipMemberForLaunch={isRemoved ? undefined : onSkipMemberForLaunch}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { MembersEditorSection } from './MembersEditorSection';
|
|||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
interface TeamRosterEditorSectionProps {
|
||||
members: MemberDraft[];
|
||||
|
|
@ -31,13 +31,10 @@ interface TeamRosterEditorSectionProps {
|
|||
providerId: TeamProviderId;
|
||||
model: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
providerFastModeDefault?: boolean;
|
||||
limitContext: boolean;
|
||||
onProviderChange: (providerId: TeamProviderId) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onEffortChange: (effort: string) => void;
|
||||
onFastModeChange?: (fastMode: TeamFastMode) => void;
|
||||
onLimitContextChange: (value: boolean) => void;
|
||||
syncModelsWithTeammates: boolean;
|
||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||
|
|
@ -45,7 +42,6 @@ interface TeamRosterEditorSectionProps {
|
|||
headerBottom?: React.ReactNode;
|
||||
softDeleteMembers?: boolean;
|
||||
leadWarningText?: string | null;
|
||||
leadFastModeNotice?: string | null;
|
||||
memberWarningById?: Record<string, string | null | undefined>;
|
||||
disableGeminiOption?: boolean;
|
||||
leadModelIssueText?: string | null;
|
||||
|
|
@ -79,13 +75,10 @@ export const TeamRosterEditorSection = ({
|
|||
providerId,
|
||||
model,
|
||||
effort,
|
||||
fastMode,
|
||||
providerFastModeDefault,
|
||||
limitContext,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onEffortChange,
|
||||
onFastModeChange,
|
||||
onLimitContextChange,
|
||||
syncModelsWithTeammates,
|
||||
onSyncModelsWithTeammatesChange,
|
||||
|
|
@ -93,7 +86,6 @@ export const TeamRosterEditorSection = ({
|
|||
headerBottom,
|
||||
softDeleteMembers = false,
|
||||
leadWarningText,
|
||||
leadFastModeNotice,
|
||||
memberWarningById,
|
||||
disableGeminiOption = false,
|
||||
leadModelIssueText,
|
||||
|
|
@ -138,18 +130,14 @@ export const TeamRosterEditorSection = ({
|
|||
providerId={providerId}
|
||||
model={model}
|
||||
effort={effort}
|
||||
fastMode={fastMode}
|
||||
providerFastModeDefault={providerFastModeDefault}
|
||||
limitContext={limitContext}
|
||||
onProviderChange={onProviderChange}
|
||||
onModelChange={onModelChange}
|
||||
onEffortChange={onEffortChange}
|
||||
onFastModeChange={onFastModeChange}
|
||||
onLimitContextChange={onLimitContextChange}
|
||||
syncModelsWithTeammates={syncModelsWithTeammates}
|
||||
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
||||
warningText={leadWarningText}
|
||||
fastModeNotice={leadFastModeNotice}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueText={leadModelIssueText}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ export interface LaunchJoinMilestones {
|
|||
processOnlyAliveCount: number;
|
||||
pendingSpawnCount: number;
|
||||
failedSpawnCount: number;
|
||||
skippedSpawnCount: number;
|
||||
}
|
||||
|
||||
type DisplayStepMilestones = LaunchJoinMilestones & {
|
||||
|
|
@ -63,6 +64,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean
|
|||
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
|
||||
}
|
||||
|
||||
function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolean {
|
||||
return entry.runtimeAlive === true && entry.livenessKind === 'runtime_process';
|
||||
}
|
||||
|
||||
function shouldPreferSnapshotEntryOverLive(
|
||||
liveEntry: MemberSpawnStatusEntry | undefined,
|
||||
snapshotEntry: MemberSpawnStatusEntry | undefined,
|
||||
|
|
@ -102,6 +107,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
|||
let processOnlyAliveCount = 0;
|
||||
let pendingSpawnCount = 0;
|
||||
let failedSpawnCount = 0;
|
||||
let skippedSpawnCount = 0;
|
||||
let observedTeammateCount = 0;
|
||||
|
||||
for (const memberName of teammateNames) {
|
||||
|
|
@ -123,15 +129,20 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
|||
failedSpawnCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||
skippedSpawnCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'confirmed_alive') {
|
||||
heartbeatConfirmedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
entry.launchState === 'runtime_pending_bootstrap' ||
|
||||
entry.launchState === 'runtime_pending_permission'
|
||||
) {
|
||||
if (entry.runtimeAlive === true) {
|
||||
if (entry.launchState === 'runtime_pending_permission') {
|
||||
pendingSpawnCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'runtime_pending_bootstrap') {
|
||||
if (isStrongRuntimeProcessSpawnEntry(entry)) {
|
||||
processOnlyAliveCount += 1;
|
||||
} else {
|
||||
pendingSpawnCount += 1;
|
||||
|
|
@ -148,6 +159,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
observedTeammateCount,
|
||||
};
|
||||
}
|
||||
|
|
@ -196,28 +208,30 @@ export function getLaunchJoinMilestonesFromMembers({
|
|||
});
|
||||
|
||||
if (snapshotSummary) {
|
||||
const snapshotProcessOnlyAliveCount = snapshotSummary.runtimeProcessPendingCount ?? 0;
|
||||
const snapshotMilestones = {
|
||||
expectedTeammateCount,
|
||||
heartbeatConfirmedCount: snapshotSummary.confirmedCount,
|
||||
processOnlyAliveCount: snapshotSummary.runtimeAlivePendingCount,
|
||||
pendingSpawnCount: Math.max(
|
||||
0,
|
||||
snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount
|
||||
),
|
||||
processOnlyAliveCount: snapshotProcessOnlyAliveCount,
|
||||
pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount),
|
||||
failedSpawnCount: snapshotSummary.failedCount,
|
||||
skippedSpawnCount: snapshotSummary.skippedCount ?? 0,
|
||||
};
|
||||
|
||||
const snapshotAccountedFor =
|
||||
snapshotMilestones.heartbeatConfirmedCount +
|
||||
snapshotMilestones.processOnlyAliveCount +
|
||||
snapshotMilestones.failedSpawnCount;
|
||||
snapshotMilestones.failedSpawnCount +
|
||||
snapshotMilestones.skippedSpawnCount;
|
||||
const liveAccountedFor =
|
||||
liveSummary.heartbeatConfirmedCount +
|
||||
liveSummary.processOnlyAliveCount +
|
||||
liveSummary.failedSpawnCount;
|
||||
liveSummary.failedSpawnCount +
|
||||
liveSummary.skippedSpawnCount;
|
||||
|
||||
const liveSummaryIsMoreAdvanced =
|
||||
liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount ||
|
||||
liveSummary.skippedSpawnCount > snapshotMilestones.skippedSpawnCount ||
|
||||
liveSummary.heartbeatConfirmedCount > snapshotMilestones.heartbeatConfirmedCount ||
|
||||
liveSummary.processOnlyAliveCount > snapshotMilestones.processOnlyAliveCount ||
|
||||
(snapshotMilestones.failedSpawnCount === 0 &&
|
||||
|
|
@ -245,6 +259,7 @@ export function getLaunchJoinState({
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
}: LaunchJoinMilestones): {
|
||||
allTeammatesConfirmedAlive: boolean;
|
||||
hasMembersStillJoining: boolean;
|
||||
|
|
@ -253,14 +268,16 @@ export function getLaunchJoinState({
|
|||
const allTeammatesConfirmedAlive =
|
||||
expectedTeammateCount > 0 &&
|
||||
failedSpawnCount === 0 &&
|
||||
skippedSpawnCount === 0 &&
|
||||
heartbeatConfirmedCount >= expectedTeammateCount;
|
||||
const remainingJoinCount =
|
||||
expectedTeammateCount > 0 && failedSpawnCount === 0
|
||||
expectedTeammateCount > 0 && failedSpawnCount === 0 && skippedSpawnCount === 0
|
||||
? Math.max(0, expectedTeammateCount - heartbeatConfirmedCount)
|
||||
: 0;
|
||||
const hasMembersStillJoining =
|
||||
expectedTeammateCount > 0 &&
|
||||
failedSpawnCount === 0 &&
|
||||
skippedSpawnCount === 0 &&
|
||||
remainingJoinCount > 0 &&
|
||||
(processOnlyAliveCount > 0 || pendingSpawnCount > 0);
|
||||
|
||||
|
|
@ -292,6 +309,7 @@ export function getDisplayStepIndex({
|
|||
processOnlyAliveCount,
|
||||
pendingSpawnCount,
|
||||
failedSpawnCount,
|
||||
skippedSpawnCount,
|
||||
}: DisplayStepMilestones): number {
|
||||
switch (progress.state) {
|
||||
case 'ready':
|
||||
|
|
@ -319,8 +337,12 @@ export function getDisplayStepIndex({
|
|||
if (failedSpawnCount > 0) {
|
||||
return 2;
|
||||
}
|
||||
if (skippedSpawnCount > 0) {
|
||||
return 2;
|
||||
}
|
||||
|
||||
const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount;
|
||||
const accountedForTeammates =
|
||||
heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount + skippedSpawnCount;
|
||||
|
||||
if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) {
|
||||
return 2;
|
||||
|
|
|
|||
|
|
@ -242,6 +242,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let memberSpawnRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let teamAgentRuntimeRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||
let inProgressChangePresencePollInFlight = false;
|
||||
let teamMessageFallbackPollInFlight = false;
|
||||
|
|
@ -286,6 +287,19 @@ export function initializeNotificationListeners(): () => void {
|
|||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
||||
memberSpawnRefreshTimers.set(teamName, timer);
|
||||
};
|
||||
const scheduleTeamAgentRuntimeRefresh = (teamName: string | null | undefined): void => {
|
||||
if (!teamName || !isTeamVisibleInAnyPane(teamName)) {
|
||||
return;
|
||||
}
|
||||
if (teamAgentRuntimeRefreshTimers.has(teamName)) {
|
||||
return;
|
||||
}
|
||||
const timer = setTimeout(() => {
|
||||
teamAgentRuntimeRefreshTimers.delete(teamName);
|
||||
void useStore.getState().fetchTeamAgentRuntime(teamName);
|
||||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
||||
teamAgentRuntimeRefreshTimers.set(teamName, timer);
|
||||
};
|
||||
const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => {
|
||||
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
||||
return;
|
||||
|
|
@ -1194,6 +1208,7 @@ export function initializeNotificationListeners(): () => void {
|
|||
}
|
||||
seedCurrentRunIdIfMissing();
|
||||
scheduleMemberSpawnStatusesRefresh(event.teamName);
|
||||
scheduleTeamAgentRuntimeRefresh(event.teamName);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -1276,6 +1291,8 @@ export function initializeNotificationListeners(): () => void {
|
|||
teamPresenceRefreshTimers = new Map();
|
||||
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
|
||||
memberSpawnRefreshTimers = new Map();
|
||||
for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t);
|
||||
teamAgentRuntimeRefreshTimers = new Map();
|
||||
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
||||
toolActivityTimers = new Map();
|
||||
teamLastRelevantActivityAt.clear();
|
||||
|
|
|
|||
|
|
@ -374,6 +374,19 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
set,
|
||||
get
|
||||
) => {
|
||||
const addMatchingReviewPathAliases = (
|
||||
aliases: Set<string>,
|
||||
filePath: string,
|
||||
canonicalFilePath: string,
|
||||
record: Record<string, unknown>
|
||||
): void => {
|
||||
for (const key of Object.keys(record)) {
|
||||
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
|
||||
aliases.add(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildResolvedFileInvalidation = (
|
||||
s: ChangeReviewSlice,
|
||||
filePath: string
|
||||
|
|
@ -388,17 +401,10 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
const existing = findReviewFileByPath(s.activeChangeSet?.files, filePath);
|
||||
const canonicalFilePath = existing?.filePath ?? filePath;
|
||||
const aliases = new Set([filePath, canonicalFilePath]);
|
||||
const addMatchingAliases = (record: Record<string, unknown>): void => {
|
||||
for (const key of Object.keys(record)) {
|
||||
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
|
||||
aliases.add(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
addMatchingAliases(s.fileChunkCounts);
|
||||
addMatchingAliases(s.fileContents);
|
||||
addMatchingAliases(s.fileContentsLoading);
|
||||
addMatchingAliases(s.fileContentVersionByPath);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentsLoading);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentVersionByPath);
|
||||
const nextFileChunkCounts = { ...s.fileChunkCounts };
|
||||
for (const alias of aliases) delete nextFileChunkCounts[alias];
|
||||
|
||||
|
|
@ -1461,18 +1467,21 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
await api.review.saveEditedFile(canonicalFilePath, content, projectPath);
|
||||
set((s) => {
|
||||
const aliases = new Set([filePath, canonicalFilePath]);
|
||||
const addMatchingAliases = (record: Record<string, unknown>): void => {
|
||||
for (const key of Object.keys(record)) {
|
||||
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
|
||||
aliases.add(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
addMatchingAliases(s.editedContents);
|
||||
addMatchingAliases(s.fileChunkCounts);
|
||||
addMatchingAliases(s.hunkContextHashesByFile);
|
||||
addMatchingAliases(s.reviewExternalChangesByFile);
|
||||
addMatchingAliases(s.fileContents);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.editedContents);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
|
||||
addMatchingReviewPathAliases(
|
||||
aliases,
|
||||
filePath,
|
||||
canonicalFilePath,
|
||||
s.hunkContextHashesByFile
|
||||
);
|
||||
addMatchingReviewPathAliases(
|
||||
aliases,
|
||||
filePath,
|
||||
canonicalFilePath,
|
||||
s.reviewExternalChangesByFile
|
||||
);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
|
||||
|
||||
const nextEdited = { ...s.editedContents };
|
||||
for (const alias of aliases) delete nextEdited[alias];
|
||||
|
|
|
|||
|
|
@ -71,14 +71,7 @@ const logger = createLogger('teamSlice');
|
|||
const TEAM_GET_DATA_TIMEOUT_MS = 30_000;
|
||||
const TEAM_FETCH_TIMEOUT_MS = 30_000;
|
||||
const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_000;
|
||||
const TEAM_DATA_IPC_WARN_MS = 350;
|
||||
const TEAM_DATA_SET_WARN_MS = 12;
|
||||
const TEAM_DATA_POST_WARN_MS = 24;
|
||||
const TEAM_DATA_LARGE_MESSAGES = 150;
|
||||
const TEAM_DATA_LARGE_TASKS = 80;
|
||||
const TEAM_REFRESH_BURST_WINDOW_MS = 4_000;
|
||||
const TEAM_REFRESH_BURST_WARN_COUNT = 5;
|
||||
const TEAM_REFRESH_WARN_THROTTLE_MS = 2_000;
|
||||
const MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
|
||||
const inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
|
||||
const inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
|
||||
|
|
@ -567,45 +560,6 @@ function fetchTeamDataFresh(teamName: string): Promise<TeamViewSnapshot> {
|
|||
);
|
||||
}
|
||||
|
||||
function summarizeTeamDataCounts(data: TeamViewSnapshot | null | undefined): {
|
||||
tasks: number;
|
||||
members: number;
|
||||
activeMembers: number;
|
||||
processes: number;
|
||||
} {
|
||||
if (!data) {
|
||||
return { tasks: 0, members: 0, activeMembers: 0, processes: 0 };
|
||||
}
|
||||
|
||||
return {
|
||||
tasks: data.tasks.length,
|
||||
members: data.members.length,
|
||||
activeMembers: data.members.filter((member) => !member.removedAt).length,
|
||||
processes: data.processes.length,
|
||||
};
|
||||
}
|
||||
|
||||
function estimateTeamPayloadWeight(data: TeamViewSnapshot): {
|
||||
taskComments: number;
|
||||
taskHistoryEvents: number;
|
||||
taskDescriptionChars: number;
|
||||
} {
|
||||
let taskComments = 0;
|
||||
let taskHistoryEvents = 0;
|
||||
let taskDescriptionChars = 0;
|
||||
for (const task of data.tasks) {
|
||||
taskComments += task.comments?.length ?? 0;
|
||||
taskHistoryEvents += task.historyEvents?.length ?? 0;
|
||||
taskDescriptionChars += task.description?.length ?? 0;
|
||||
}
|
||||
|
||||
return {
|
||||
taskComments,
|
||||
taskHistoryEvents,
|
||||
taskDescriptionChars,
|
||||
};
|
||||
}
|
||||
|
||||
function noteTeamRefreshBurst(teamName: string): number {
|
||||
const now = Date.now();
|
||||
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
|
||||
|
|
@ -621,77 +575,10 @@ function noteTeamRefreshBurst(teamName: string): number {
|
|||
|
||||
diagnostic.count += 1;
|
||||
|
||||
if (
|
||||
diagnostic.count >= TEAM_REFRESH_BURST_WARN_COUNT &&
|
||||
now - diagnostic.lastWarnAt >= TEAM_REFRESH_WARN_THROTTLE_MS
|
||||
) {
|
||||
diagnostic.lastWarnAt = now;
|
||||
logger.warn(
|
||||
`[perf] refreshTeamData burst team=${teamName} count=${diagnostic.count} windowMs=${
|
||||
now - diagnostic.windowStartedAt
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
teamRefreshBurstDiagnostics.set(teamName, diagnostic);
|
||||
return diagnostic.count;
|
||||
}
|
||||
|
||||
function maybeLogTeamDataPerf(params: {
|
||||
phase: 'selectTeam' | 'refreshTeamData';
|
||||
teamName: string;
|
||||
ipcMs: number;
|
||||
setMs: number;
|
||||
postMs: number;
|
||||
totalMs: number;
|
||||
previousData: TeamViewSnapshot | null | undefined;
|
||||
nextData: TeamViewSnapshot;
|
||||
deduped: boolean;
|
||||
reusedInFlightRequest: boolean;
|
||||
burstCount?: number;
|
||||
}): void {
|
||||
const {
|
||||
phase,
|
||||
teamName,
|
||||
ipcMs,
|
||||
setMs,
|
||||
postMs,
|
||||
totalMs,
|
||||
previousData,
|
||||
nextData,
|
||||
deduped,
|
||||
reusedInFlightRequest,
|
||||
burstCount,
|
||||
} = params;
|
||||
|
||||
const nextCounts = summarizeTeamDataCounts(nextData);
|
||||
const previousCounts = summarizeTeamDataCounts(previousData);
|
||||
const largePayload = nextCounts.tasks >= TEAM_DATA_LARGE_TASKS;
|
||||
const slow =
|
||||
ipcMs >= TEAM_DATA_IPC_WARN_MS ||
|
||||
setMs >= TEAM_DATA_SET_WARN_MS ||
|
||||
postMs >= TEAM_DATA_POST_WARN_MS;
|
||||
|
||||
if (!slow && !largePayload && !reusedInFlightRequest) {
|
||||
return;
|
||||
}
|
||||
|
||||
const payloadWeight = estimateTeamPayloadWeight(nextData);
|
||||
logger.warn(
|
||||
`[perf] ${phase} team=${teamName} ipc=${ipcMs.toFixed(1)}ms set=${setMs.toFixed(
|
||||
1
|
||||
)}ms post=${postMs.toFixed(1)}ms total=${totalMs.toFixed(1)}ms deduped=${deduped} reusedInFlight=${
|
||||
reusedInFlightRequest ? 'yes' : 'no'
|
||||
} burst=${burstCount ?? 1} counts=tasks:${previousCounts.tasks}->${nextCounts.tasks},members:${
|
||||
previousCounts.members
|
||||
}->${nextCounts.members},activeMembers:${
|
||||
previousCounts.activeMembers
|
||||
}->${nextCounts.activeMembers},processes:${previousCounts.processes}->${nextCounts.processes} payload=textChars:${
|
||||
payloadWeight.taskDescriptionChars
|
||||
},taskComments=${payloadWeight.taskComments},historyEvents=${payloadWeight.taskHistoryEvents}`
|
||||
);
|
||||
}
|
||||
|
||||
function areLaunchSummaryCountsEqual(
|
||||
left: PersistedTeamLaunchSummary | undefined,
|
||||
right: PersistedTeamLaunchSummary | undefined
|
||||
|
|
@ -702,7 +589,13 @@ function areLaunchSummaryCountsEqual(
|
|||
left.confirmedCount === right.confirmedCount &&
|
||||
left.pendingCount === right.pendingCount &&
|
||||
left.failedCount === right.failedCount &&
|
||||
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount
|
||||
left.skippedCount === right.skippedCount &&
|
||||
left.runtimeAlivePendingCount === right.runtimeAlivePendingCount &&
|
||||
left.shellOnlyPendingCount === right.shellOnlyPendingCount &&
|
||||
left.runtimeProcessPendingCount === right.runtimeProcessPendingCount &&
|
||||
left.runtimeCandidatePendingCount === right.runtimeCandidatePendingCount &&
|
||||
left.noRuntimePendingCount === right.noRuntimePendingCount &&
|
||||
left.permissionPendingCount === right.permissionPendingCount
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -736,9 +629,15 @@ function areMemberSpawnStatusEntriesEqual(
|
|||
left.launchState === right.launchState &&
|
||||
left.error === right.error &&
|
||||
left.hardFailureReason === right.hardFailureReason &&
|
||||
left.skippedForLaunch === right.skippedForLaunch &&
|
||||
left.skipReason === right.skipReason &&
|
||||
left.skippedAt === right.skippedAt &&
|
||||
left.livenessSource === right.livenessSource &&
|
||||
left.runtimeAlive === right.runtimeAlive &&
|
||||
left.runtimeModel === right.runtimeModel &&
|
||||
left.livenessKind === right.livenessKind &&
|
||||
left.runtimeDiagnostic === right.runtimeDiagnostic &&
|
||||
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
|
||||
left.bootstrapConfirmed === right.bootstrapConfirmed &&
|
||||
left.hardFailure === right.hardFailure &&
|
||||
leftPendingPermissionIds.length === rightPendingPermissionIds.length &&
|
||||
|
|
@ -802,14 +701,34 @@ function areTeamAgentRuntimeEntriesEqual(
|
|||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return left === right;
|
||||
const leftDiagnostics = left.diagnostics ?? [];
|
||||
const rightDiagnostics = right.diagnostics ?? [];
|
||||
return (
|
||||
left.memberName === right.memberName &&
|
||||
left.alive === right.alive &&
|
||||
left.restartable === right.restartable &&
|
||||
left.backendType === right.backendType &&
|
||||
left.providerId === right.providerId &&
|
||||
left.providerBackendId === right.providerBackendId &&
|
||||
left.laneId === right.laneId &&
|
||||
left.laneKind === right.laneKind &&
|
||||
left.pid === right.pid &&
|
||||
left.runtimeModel === right.runtimeModel &&
|
||||
left.rssBytes === right.rssBytes
|
||||
left.rssBytes === right.rssBytes &&
|
||||
left.livenessKind === right.livenessKind &&
|
||||
left.pidSource === right.pidSource &&
|
||||
left.processCommand === right.processCommand &&
|
||||
left.paneId === right.paneId &&
|
||||
left.panePid === right.panePid &&
|
||||
left.paneCurrentCommand === right.paneCurrentCommand &&
|
||||
left.runtimePid === right.runtimePid &&
|
||||
left.runtimeSessionId === right.runtimeSessionId &&
|
||||
left.runtimeDiagnostic === right.runtimeDiagnostic &&
|
||||
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
|
||||
left.runtimeLastSeenAt === right.runtimeLastSeenAt &&
|
||||
left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed &&
|
||||
leftDiagnostics.length === rightDiagnostics.length &&
|
||||
leftDiagnostics.every((value, index) => value === rightDiagnostics[index])
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -2134,6 +2053,7 @@ export interface TeamSlice {
|
|||
) => Promise<TaskComment>;
|
||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||
restartMember: (teamName: string, memberName: string) => Promise<void>;
|
||||
skipMemberForLaunch: (teamName: string, memberName: string) => Promise<void>;
|
||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||
updateMemberRole: (
|
||||
teamName: string,
|
||||
|
|
@ -3211,7 +3131,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
},
|
||||
|
||||
selectTeam: async (teamName: string, opts) => {
|
||||
const startedAt = performance.now();
|
||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
||||
// Guard: prevent duplicate in-flight fetches for the same team.
|
||||
|
|
@ -3244,7 +3163,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||
return;
|
||||
}
|
||||
const ipcMs = performance.now() - startedAt;
|
||||
// Stale check: user may have switched to another team during the async call
|
||||
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
|
||||
return;
|
||||
|
|
@ -3276,7 +3194,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
: data;
|
||||
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
||||
const setStartedAt = performance.now();
|
||||
set((state) => {
|
||||
const nextCache =
|
||||
state.teamDataCacheByName[teamName] === nextTeamData
|
||||
|
|
@ -3295,8 +3212,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
};
|
||||
});
|
||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||
const setMs = performance.now() - setStartedAt;
|
||||
const postStartedAt = performance.now();
|
||||
const invalidationState = previousData
|
||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||
: { cacheKeys: [], taskIds: [] };
|
||||
|
|
@ -3306,19 +3221,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (invalidationState.taskIds.length > 0) {
|
||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
||||
}
|
||||
const postMs = performance.now() - postStartedAt;
|
||||
maybeLogTeamDataPerf({
|
||||
phase: 'selectTeam',
|
||||
teamName,
|
||||
ipcMs,
|
||||
setMs,
|
||||
postMs,
|
||||
totalMs: performance.now() - startedAt,
|
||||
previousData,
|
||||
nextData: nextTeamData,
|
||||
deduped: true,
|
||||
reusedInFlightRequest: false,
|
||||
});
|
||||
// Sync tab label with the team's display name from config
|
||||
const displayName = data.config.name || teamName;
|
||||
const allTabs = get().getAllPaneTabs();
|
||||
|
|
@ -3422,19 +3324,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
},
|
||||
|
||||
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
||||
const startedAt = performance.now();
|
||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||
const refreshToken = beginInFlightTeamDataRefresh(teamName);
|
||||
// Silent refresh — update data without showing loading skeleton.
|
||||
// Only selectTeam() sets loading: true (for initial load).
|
||||
const reusedInFlightRequest =
|
||||
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
|
||||
const burstCount = noteTeamRefreshBurst(teamName);
|
||||
noteTeamRefreshBurst(teamName);
|
||||
if (reusedInFlightRequest) {
|
||||
pendingFreshTeamDataRefreshes.add(teamName);
|
||||
logger.warn(
|
||||
`[perf] refreshTeamData queued-fresh team=${teamName} burst=${burstCount} reason=inFlightDedup`
|
||||
);
|
||||
}
|
||||
try {
|
||||
const previousData = selectTeamDataForName(get(), teamName);
|
||||
|
|
@ -3444,7 +3342,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||
return;
|
||||
}
|
||||
const ipcMs = performance.now() - startedAt;
|
||||
const projectedTeamData = previousData
|
||||
? {
|
||||
...data,
|
||||
|
|
@ -3452,7 +3349,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
: data;
|
||||
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
||||
const setStartedAt = performance.now();
|
||||
set((state) => {
|
||||
const nextCache =
|
||||
state.teamDataCacheByName[teamName] === nextTeamData
|
||||
|
|
@ -3484,8 +3380,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
};
|
||||
});
|
||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||
const setMs = performance.now() - setStartedAt;
|
||||
const postStartedAt = performance.now();
|
||||
const invalidationState = previousData
|
||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||
: { cacheKeys: [], taskIds: [] };
|
||||
|
|
@ -3495,20 +3389,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
if (invalidationState.taskIds.length > 0) {
|
||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
||||
}
|
||||
const postMs = performance.now() - postStartedAt;
|
||||
maybeLogTeamDataPerf({
|
||||
phase: 'refreshTeamData',
|
||||
teamName,
|
||||
ipcMs,
|
||||
setMs,
|
||||
postMs,
|
||||
totalMs: performance.now() - startedAt,
|
||||
previousData,
|
||||
nextData: nextTeamData,
|
||||
deduped: opts?.withDedup === true,
|
||||
reusedInFlightRequest,
|
||||
burstCount,
|
||||
});
|
||||
} catch (error) {
|
||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||
return;
|
||||
|
|
@ -4227,6 +4107,20 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
|||
}
|
||||
},
|
||||
|
||||
skipMemberForLaunch: async (teamName: string, memberName: string) => {
|
||||
try {
|
||||
await unwrapIpc('team:skipMemberForLaunch', () =>
|
||||
api.teams.skipMemberForLaunch(teamName, memberName)
|
||||
);
|
||||
} finally {
|
||||
await Promise.allSettled([
|
||||
get().fetchMemberSpawnStatuses(teamName),
|
||||
get().fetchTeamAgentRuntime(teamName),
|
||||
get().fetchTeams(),
|
||||
]);
|
||||
}
|
||||
},
|
||||
|
||||
removeMember: async (teamName: string, memberName: string) => {
|
||||
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
|
||||
await get().refreshTeamData(teamName);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
|
||||
getAvailableTeamProviderModelOptions,
|
||||
getAvailableTeamProviderModels,
|
||||
getTeamModelSelectionError,
|
||||
|
|
@ -151,7 +150,7 @@ describe('team model availability Codex catalog integration', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('shows app-server future models but blocks launch until runtime declares dynamic support', () => {
|
||||
it('allows app-server catalog models even when the runtime does not declare dynamic model launch', () => {
|
||||
const providerStatus = createCodexProviderStatus([
|
||||
{
|
||||
id: 'gpt-5.5',
|
||||
|
|
@ -168,16 +167,14 @@ describe('team model availability Codex catalog integration', () => {
|
|||
},
|
||||
]);
|
||||
|
||||
expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual([]);
|
||||
expect(getAvailableTeamProviderModels('codex', providerStatus)).toEqual(['gpt-5.5']);
|
||||
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({
|
||||
value: 'gpt-5.5',
|
||||
label: '5.5',
|
||||
badgeLabel: 'New',
|
||||
availabilityStatus: null,
|
||||
availabilityStatus: 'available',
|
||||
});
|
||||
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toContain(
|
||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON
|
||||
);
|
||||
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull();
|
||||
});
|
||||
|
||||
it('keeps existing disabled model policy on top of the dynamic catalog', () => {
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
MemberSpawnStatus,
|
||||
MemberStatus,
|
||||
ResolvedTeamMember,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamProviderId,
|
||||
TeamReviewState,
|
||||
TeamTaskStatus,
|
||||
|
|
@ -118,6 +119,7 @@ export const SPAWN_DOT_COLORS: Record<MemberSpawnStatus, string> = {
|
|||
spawning: 'bg-amber-400',
|
||||
online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]',
|
||||
error: 'bg-red-400',
|
||||
skipped: 'bg-zinc-500',
|
||||
};
|
||||
|
||||
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||
|
|
@ -126,6 +128,7 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
|||
spawning: 'starting',
|
||||
online: 'ready',
|
||||
error: 'spawn failed',
|
||||
skipped: 'skipped',
|
||||
};
|
||||
|
||||
function isLaunchStillStarting(
|
||||
|
|
@ -137,6 +140,9 @@ function isLaunchStillStarting(
|
|||
if (spawnLaunchState === 'failed_to_start') {
|
||||
return false;
|
||||
}
|
||||
if (spawnLaunchState === 'skipped_for_launch') {
|
||||
return false;
|
||||
}
|
||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -170,6 +176,9 @@ export function getSpawnAwareDotClass(
|
|||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||
return SPAWN_DOT_COLORS.error;
|
||||
}
|
||||
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||
return SPAWN_DOT_COLORS.skipped;
|
||||
}
|
||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||
return 'bg-amber-400 animate-pulse';
|
||||
}
|
||||
|
|
@ -217,6 +226,9 @@ export function getSpawnAwarePresenceLabel(
|
|||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||
return SPAWN_PRESENCE_LABELS.error;
|
||||
}
|
||||
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||
return SPAWN_PRESENCE_LABELS.skipped;
|
||||
}
|
||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||
return 'connecting';
|
||||
}
|
||||
|
|
@ -258,6 +270,9 @@ export function getSpawnCardClass(
|
|||
) {
|
||||
return 'member-waiting-shimmer';
|
||||
}
|
||||
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||
return 'opacity-70';
|
||||
}
|
||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||
return 'member-waiting-shimmer';
|
||||
}
|
||||
|
|
@ -517,6 +532,7 @@ export function getLaunchAwarePresenceLabel(
|
|||
basePresenceLabel === 'starting' ||
|
||||
basePresenceLabel === 'connecting' ||
|
||||
basePresenceLabel === 'spawn failed' ||
|
||||
basePresenceLabel === 'skipped' ||
|
||||
basePresenceLabel === 'offline' ||
|
||||
basePresenceLabel === 'terminated'
|
||||
) {
|
||||
|
|
@ -531,8 +547,13 @@ export type MemberLaunchVisualState =
|
|||
| 'spawning'
|
||||
| 'permission_pending'
|
||||
| 'runtime_pending'
|
||||
| 'shell_only'
|
||||
| 'runtime_candidate'
|
||||
| 'registered_only'
|
||||
| 'stale_runtime'
|
||||
| 'settling'
|
||||
| 'error'
|
||||
| 'skipped'
|
||||
| null;
|
||||
|
||||
export interface MemberLaunchPresentation {
|
||||
|
|
@ -556,11 +577,21 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
|
|||
case 'permission_pending':
|
||||
return 'awaiting permission';
|
||||
case 'runtime_pending':
|
||||
return 'connecting';
|
||||
return 'waiting for bootstrap';
|
||||
case 'shell_only':
|
||||
return 'shell only';
|
||||
case 'runtime_candidate':
|
||||
return 'process candidate';
|
||||
case 'registered_only':
|
||||
return 'registered';
|
||||
case 'stale_runtime':
|
||||
return 'stale runtime';
|
||||
case 'settling':
|
||||
return 'joining team';
|
||||
case 'error':
|
||||
return 'failed';
|
||||
case 'skipped':
|
||||
return 'skipped';
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
|
|
@ -573,6 +604,7 @@ export function buildMemberLaunchPresentation({
|
|||
spawnLivenessSource,
|
||||
spawnRuntimeAlive,
|
||||
runtimeAdvisory,
|
||||
runtimeEntry,
|
||||
isLaunchSettling = false,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
|
|
@ -584,6 +616,7 @@ export function buildMemberLaunchPresentation({
|
|||
spawnLivenessSource: MemberSpawnLivenessSource | undefined;
|
||||
spawnRuntimeAlive: boolean | undefined;
|
||||
runtimeAdvisory: MemberRuntimeAdvisory | undefined;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
isLaunchSettling?: boolean;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
|
|
@ -628,14 +661,21 @@ export function buildMemberLaunchPresentation({
|
|||
if (isTeamAlive !== false || isTeamProvisioning) {
|
||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||
launchVisualState = 'error';
|
||||
} else if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||
launchVisualState = 'skipped';
|
||||
} else if (spawnLaunchState === 'runtime_pending_permission') {
|
||||
launchVisualState = 'permission_pending';
|
||||
} else if (runtimeEntry?.livenessKind === 'shell_only') {
|
||||
launchVisualState = 'shell_only';
|
||||
} else if (runtimeEntry?.livenessKind === 'runtime_process_candidate') {
|
||||
launchVisualState = 'runtime_candidate';
|
||||
} else if (runtimeEntry?.livenessKind === 'registered_only') {
|
||||
launchVisualState = 'registered_only';
|
||||
} else if (
|
||||
spawnLaunchState === 'runtime_pending_bootstrap' &&
|
||||
spawnStatus === 'online' &&
|
||||
spawnRuntimeAlive === true
|
||||
runtimeEntry?.livenessKind === 'stale_metadata' ||
|
||||
runtimeEntry?.livenessKind === 'not_found'
|
||||
) {
|
||||
launchVisualState = 'runtime_pending';
|
||||
launchVisualState = 'stale_runtime';
|
||||
} else if (
|
||||
isLaunchStillStarting(
|
||||
spawnStatus,
|
||||
|
|
@ -645,6 +685,12 @@ export function buildMemberLaunchPresentation({
|
|||
)
|
||||
) {
|
||||
launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
|
||||
} else if (
|
||||
spawnLaunchState === 'runtime_pending_bootstrap' &&
|
||||
(runtimeEntry?.livenessKind === 'runtime_process' ||
|
||||
(spawnStatus === 'online' && spawnRuntimeAlive === true))
|
||||
) {
|
||||
launchVisualState = 'runtime_pending';
|
||||
} else if (
|
||||
isLaunchSettling &&
|
||||
spawnStatus === 'online' &&
|
||||
|
|
@ -655,6 +701,19 @@ export function buildMemberLaunchPresentation({
|
|||
}
|
||||
|
||||
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
|
||||
const shouldShowLaunchStatusAsPresence =
|
||||
launchVisualState === 'permission_pending' ||
|
||||
launchVisualState === 'runtime_pending' ||
|
||||
launchVisualState === 'shell_only' ||
|
||||
launchVisualState === 'runtime_candidate' ||
|
||||
launchVisualState === 'registered_only' ||
|
||||
launchVisualState === 'stale_runtime';
|
||||
const displayPresenceLabel =
|
||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||
? runtimeAdvisoryLabel
|
||||
: shouldShowLaunchStatusAsPresence
|
||||
? (launchStatusLabel ?? presenceLabel)
|
||||
: presenceLabel;
|
||||
const spawnBadgeLabel =
|
||||
spawnStatus && spawnStatus !== 'online'
|
||||
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
||||
|
|
@ -663,7 +722,7 @@ export function buildMemberLaunchPresentation({
|
|||
: null;
|
||||
|
||||
return {
|
||||
presenceLabel,
|
||||
presenceLabel: displayPresenceLabel,
|
||||
dotClass: runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : dotClass,
|
||||
cardClass,
|
||||
runtimeAdvisoryLabel,
|
||||
|
|
|
|||
189
src/renderer/utils/memberLaunchDiagnostics.ts
Normal file
189
src/renderer/utils/memberLaunchDiagnostics.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import type {
|
||||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
MemberSpawnStatusEntry,
|
||||
TeamAgentRuntimeDiagnosticSeverity,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamAgentRuntimeLivenessKind,
|
||||
TeamAgentRuntimePidSource,
|
||||
} from '@shared/types';
|
||||
|
||||
export interface MemberLaunchDiagnosticsPayload {
|
||||
teamName?: string;
|
||||
runId?: string;
|
||||
memberName: string;
|
||||
launchState?: MemberLaunchState;
|
||||
spawnStatus?: MemberSpawnStatus;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
livenessSource?: MemberSpawnLivenessSource;
|
||||
pid?: number;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
paneId?: string;
|
||||
panePid?: number;
|
||||
paneCurrentCommand?: string;
|
||||
processCommand?: string;
|
||||
runtimePid?: number;
|
||||
runtimeSessionId?: string;
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
diagnostics?: string[];
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
const MAX_DIAGNOSTIC_STRING_LENGTH = 500;
|
||||
const MAX_DIAGNOSTIC_ITEMS = 20;
|
||||
const SECRET_FLAG_PATTERN =
|
||||
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
|
||||
|
||||
function boundedString(
|
||||
value: string | undefined,
|
||||
maxLength = MAX_DIAGNOSTIC_STRING_LENGTH
|
||||
): string | undefined {
|
||||
const trimmed = value?.replace(/\s+/g, ' ').trim();
|
||||
if (!trimmed) return undefined;
|
||||
const redacted = trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]');
|
||||
return redacted.length > maxLength
|
||||
? `${redacted.slice(0, Math.max(0, maxLength - 3))}...`
|
||||
: redacted;
|
||||
}
|
||||
|
||||
function boundedNumber(value: number | undefined): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? Math.trunc(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function uniqueDiagnostics(
|
||||
...groups: Array<readonly (string | undefined)[] | undefined>
|
||||
): string[] | undefined {
|
||||
const seen = new Set<string>();
|
||||
const diagnostics: string[] = [];
|
||||
for (const group of groups) {
|
||||
for (const item of group ?? []) {
|
||||
const normalized = boundedString(item);
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
diagnostics.push(normalized);
|
||||
if (diagnostics.length >= MAX_DIAGNOSTIC_ITEMS) {
|
||||
return diagnostics;
|
||||
}
|
||||
}
|
||||
}
|
||||
return diagnostics.length > 0 ? diagnostics : undefined;
|
||||
}
|
||||
|
||||
export function buildMemberLaunchDiagnosticsPayload(params: {
|
||||
teamName?: string | null;
|
||||
runId?: string | null;
|
||||
memberName: string;
|
||||
spawnStatus?: MemberSpawnStatus;
|
||||
launchState?: MemberLaunchState;
|
||||
livenessSource?: MemberSpawnLivenessSource;
|
||||
spawnEntry?: MemberSpawnStatusEntry;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
}): MemberLaunchDiagnosticsPayload {
|
||||
const spawnEntry = params.spawnEntry;
|
||||
const runtimeEntry = params.runtimeEntry;
|
||||
const runtimeDiagnostic =
|
||||
boundedString(spawnEntry?.runtimeDiagnostic) ??
|
||||
boundedString(runtimeEntry?.runtimeDiagnostic) ??
|
||||
boundedString(spawnEntry?.hardFailureReason) ??
|
||||
boundedString(spawnEntry?.error);
|
||||
const diagnostics = uniqueDiagnostics(
|
||||
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
|
||||
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
|
||||
spawnEntry?.error ? [spawnEntry.error] : undefined,
|
||||
runtimeEntry?.diagnostics
|
||||
);
|
||||
const runId = boundedString(params.runId ?? undefined);
|
||||
|
||||
return {
|
||||
...(params.teamName ? { teamName: params.teamName } : {}),
|
||||
...(runId ? { runId } : {}),
|
||||
memberName: params.memberName,
|
||||
...((spawnEntry?.launchState ?? params.launchState)
|
||||
? { launchState: spawnEntry?.launchState ?? params.launchState }
|
||||
: {}),
|
||||
...((spawnEntry?.status ?? params.spawnStatus)
|
||||
? { spawnStatus: spawnEntry?.status ?? params.spawnStatus }
|
||||
: {}),
|
||||
...((spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind)
|
||||
? { livenessKind: spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind }
|
||||
: {}),
|
||||
...((spawnEntry?.livenessSource ?? params.livenessSource)
|
||||
? { livenessSource: spawnEntry?.livenessSource ?? params.livenessSource }
|
||||
: {}),
|
||||
...(boundedNumber(runtimeEntry?.pid) ? { pid: boundedNumber(runtimeEntry?.pid) } : {}),
|
||||
...(runtimeEntry?.pidSource ? { pidSource: runtimeEntry.pidSource } : {}),
|
||||
...(boundedString(runtimeEntry?.paneId) ? { paneId: boundedString(runtimeEntry?.paneId) } : {}),
|
||||
...(boundedNumber(runtimeEntry?.panePid)
|
||||
? { panePid: boundedNumber(runtimeEntry?.panePid) }
|
||||
: {}),
|
||||
...(boundedString(runtimeEntry?.paneCurrentCommand)
|
||||
? { paneCurrentCommand: boundedString(runtimeEntry?.paneCurrentCommand) }
|
||||
: {}),
|
||||
...(boundedString(runtimeEntry?.processCommand)
|
||||
? { processCommand: boundedString(runtimeEntry?.processCommand) }
|
||||
: {}),
|
||||
...(boundedNumber(runtimeEntry?.runtimePid)
|
||||
? { runtimePid: boundedNumber(runtimeEntry?.runtimePid) }
|
||||
: {}),
|
||||
...(boundedString(runtimeEntry?.runtimeSessionId)
|
||||
? { runtimeSessionId: boundedString(runtimeEntry?.runtimeSessionId) }
|
||||
: {}),
|
||||
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||
...((spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity)
|
||||
? {
|
||||
runtimeDiagnosticSeverity:
|
||||
spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity,
|
||||
}
|
||||
: {}),
|
||||
...(diagnostics ? { diagnostics } : {}),
|
||||
...(boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt)
|
||||
? { updatedAt: boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasMemberLaunchDiagnosticsDetails(
|
||||
payload: MemberLaunchDiagnosticsPayload
|
||||
): boolean {
|
||||
const weakLiveness =
|
||||
payload.livenessKind === 'runtime_process_candidate' ||
|
||||
payload.livenessKind === 'permission_blocked' ||
|
||||
payload.livenessKind === 'shell_only' ||
|
||||
payload.livenessKind === 'registered_only' ||
|
||||
payload.livenessKind === 'stale_metadata' ||
|
||||
payload.livenessKind === 'not_found';
|
||||
return Boolean(
|
||||
(payload.launchState && payload.launchState !== 'confirmed_alive') ||
|
||||
(payload.spawnStatus && payload.spawnStatus !== 'online') ||
|
||||
weakLiveness ||
|
||||
payload.runtimeDiagnostic ||
|
||||
payload.diagnostics?.length
|
||||
);
|
||||
}
|
||||
|
||||
export function hasMemberLaunchDiagnosticsError(payload: MemberLaunchDiagnosticsPayload): boolean {
|
||||
return Boolean(
|
||||
payload.spawnStatus === 'error' ||
|
||||
payload.launchState === 'failed_to_start' ||
|
||||
payload.runtimeDiagnosticSeverity === 'error'
|
||||
);
|
||||
}
|
||||
|
||||
export function getMemberLaunchDiagnosticsErrorMessage(
|
||||
payload: MemberLaunchDiagnosticsPayload
|
||||
): string | undefined {
|
||||
if (!hasMemberLaunchDiagnosticsError(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
return payload.runtimeDiagnostic ?? payload.diagnostics?.[0] ?? 'Launch failed';
|
||||
}
|
||||
|
||||
export function formatMemberLaunchDiagnosticsPayload(
|
||||
payload: MemberLaunchDiagnosticsPayload
|
||||
): string {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
|
@ -40,6 +40,37 @@ function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined):
|
|||
);
|
||||
}
|
||||
|
||||
export function getRuntimeMemorySourceLabel(
|
||||
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||
): string | undefined {
|
||||
if (!runtimeEntry?.pidSource) {
|
||||
return undefined;
|
||||
}
|
||||
if (runtimeEntry.pidSource === 'tmux_pane') {
|
||||
return 'RSS source: tmux pane shell';
|
||||
}
|
||||
if (
|
||||
runtimeEntry.providerId === 'opencode' &&
|
||||
runtimeEntry.restartable === false &&
|
||||
runtimeEntry.pidSource === 'opencode_bridge'
|
||||
) {
|
||||
return 'RSS source: shared OpenCode host';
|
||||
}
|
||||
if (runtimeEntry.pidSource === 'tmux_child' || runtimeEntry.pidSource === 'agent_process_table') {
|
||||
return 'RSS source: runtime process';
|
||||
}
|
||||
if (runtimeEntry.pidSource === 'lead_process') {
|
||||
return 'RSS source: lead process';
|
||||
}
|
||||
if (runtimeEntry.pidSource === 'runtime_bootstrap') {
|
||||
return 'RSS source: runtime bootstrap process';
|
||||
}
|
||||
if (runtimeEntry.pidSource === 'persisted_metadata') {
|
||||
return 'RSS source: persisted runtime metadata';
|
||||
}
|
||||
return `PID source: ${runtimeEntry.pidSource}`;
|
||||
}
|
||||
|
||||
export function resolveMemberRuntimeSummary(
|
||||
member: ResolvedTeamMember,
|
||||
launchParams: TeamLaunchParams | undefined,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ export function buildPendingRuntimeSummaryCopy(input: {
|
|||
confirmedCount?: number | null;
|
||||
expectedMemberCount?: number | null;
|
||||
memberCount?: number | null;
|
||||
runtimeAlivePendingCount?: number | null;
|
||||
runtimeProcessPendingCount?: number | null;
|
||||
includePeriod?: boolean;
|
||||
}): string {
|
||||
const pendingCount = input.runtimeAlivePendingCount ?? 0;
|
||||
const pendingCount = input.runtimeProcessPendingCount ?? 0;
|
||||
if (pendingCount <= 0) {
|
||||
return input.includePeriod
|
||||
? 'Last launch is still reconciling.'
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import type {
|
|||
} from '@shared/types';
|
||||
|
||||
export {
|
||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
|
||||
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_REASON,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_MODEL,
|
||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue