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',
|
||||
{
|
||||
allowLeadAliases: true,
|
||||
}
|
||||
);
|
||||
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 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: 'none',
|
||||
source: `${source}_status_reset`,
|
||||
};
|
||||
}
|
||||
|
||||
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);
|
||||
if (persisted !== 'none') {
|
||||
return {
|
||||
state: persisted,
|
||||
source: 'task_review_state',
|
||||
};
|
||||
const persistedFallback = normalizeFallback(persisted, 'task_review_state');
|
||||
if (persistedFallback) {
|
||||
return persistedFallback;
|
||||
}
|
||||
|
||||
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
|
||||
return {
|
||||
state: normalizeReviewState(kanbanEntry.column),
|
||||
source: 'kanban_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
|
|
@ -1,7 +1,7 @@
|
|||
# OpenCode Native Semantic Messaging Plan
|
||||
|
||||
Status: planning document
|
||||
Scope: `claude_team` + `agent_teams_orchestrator`
|
||||
Status: planning document
|
||||
Scope: `claude_team` + `agent_teams_orchestrator`
|
||||
Goal: make OpenCode teammates use the correct app MCP messaging protocol without breaking Codex/Claude native teammates.
|
||||
|
||||
## Problem
|
||||
|
|
@ -30,13 +30,13 @@ This can make OpenCode teammates look started but not answer through the Message
|
|||
|
||||
Chosen approach: OpenCode-native semantic messaging seam.
|
||||
|
||||
Option 1: frontend-only display patch - 🎯 2 🛡️ 2 🧠 2, about 50-120 LOC
|
||||
Option 1: frontend-only display patch - 🎯 2 🛡️ 2 🧠 2, about 50-120 LOC
|
||||
This hides symptoms only. It does not fix the wrong tool instructions sent to OpenCode.
|
||||
|
||||
Option 2: orchestrator-only patch - 🎯 6 🛡️ 6 🧠 4, about 180-320 LOC
|
||||
Option 2: orchestrator-only patch - 🎯 6 🛡️ 6 🧠 4, about 180-320 LOC
|
||||
This is necessary for runtime identity and MCP proof, but not sufficient because `member_briefing` and task assignment messages are produced in `claude_team`.
|
||||
|
||||
Option 3: orchestrator + `claude_team` controller/MCP semantic seam - 🎯 9 🛡️ 9 🧠 7, about 1300-2200 LOC with tests
|
||||
Option 3: orchestrator + `claude_team` controller/MCP semantic seam - 🎯 9 🛡️ 9 🧠 7, about 1300-2200 LOC with tests
|
||||
This fixes the actual contract. Orchestrator owns OpenCode session identity. `claude_team` owns team protocol text and MCP tool schemas.
|
||||
|
||||
## Extra Research Corrections
|
||||
|
|
@ -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.`);
|
||||
|
|
@ -837,13 +841,13 @@ Current fact:
|
|||
|
||||
Options:
|
||||
|
||||
Option A: keep cross-team `taskRefs` out of v1 prompts - 🎯 8 🛡️ 8 🧠 2, about 0-25 LOC
|
||||
Option A: keep cross-team `taskRefs` out of v1 prompts - 🎯 8 🛡️ 8 🧠 2, about 0-25 LOC
|
||||
Safest if we want the smallest messaging seam. The helper must not accept or render `taskRefs` for `buildCrossTeamMessageExample()` yet.
|
||||
|
||||
Option B: wire cross-team `taskRefs` end-to-end now - 🎯 8 🛡️ 9 🧠 4, about 70-150 LOC
|
||||
Option B: wire cross-team `taskRefs` end-to-end now - 🎯 8 🛡️ 9 🧠 4, about 70-150 LOC
|
||||
Best if the helper is meant to be a real semantic messaging seam with uniform traceability. Add `taskRefs` to `cross_team_send` schema, normalize it in controller, store it in target inbox row, append it to sent message, and persist it in `sent-cross-team.json`.
|
||||
|
||||
Chosen for v1 if Step 1 helper has a generic `taskRefs` option: Option B.
|
||||
Chosen for v1 if Step 1 helper has a generic `taskRefs` option: Option B.
|
||||
Chosen for v1 if Step 1 helper only renders static examples: Option A.
|
||||
|
||||
Implementation for Option B:
|
||||
|
|
@ -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`.
|
||||
|
|
@ -1135,13 +1138,13 @@ Do not add this as a serial wait inside the existing member loop.
|
|||
|
||||
Options:
|
||||
|
||||
Option A: serial settle inside the existing loop - 🎯 4 🛡️ 5 🧠 2, about 30-60 LOC
|
||||
Option A: serial settle inside the existing loop - 🎯 4 🛡️ 5 🧠 2, about 30-60 LOC
|
||||
Easy, but bad for UX. Three OpenCode teammates with an 8 second preview cap can add 24 seconds of launch latency.
|
||||
|
||||
Option B: two-phase launch with bounded concurrent settle - 🎯 8 🛡️ 8 🧠 5, about 140-260 LOC
|
||||
Option B: two-phase launch with bounded concurrent settle - 🎯 8 🛡️ 8 🧠 5, about 140-260 LOC
|
||||
First ensure sessions and enqueue prompts for all members. Then run bounded preview/reconcile concurrently per prompted member with a small local concurrency cap. This fixes early false `created` without multiplying wait time by teammate count or opening one preview stream per teammate in large teams.
|
||||
|
||||
Option C: no settle, rely only on later reconcile - 🎯 5 🛡️ 6 🧠 1, about 0-20 LOC
|
||||
Option C: no settle, rely only on later reconcile - 🎯 5 🛡️ 6 🧠 1, about 0-20 LOC
|
||||
Avoids launch delay, but keeps the stale/early UI state that caused OpenCode teammates to look unspawned or stuck.
|
||||
|
||||
Chosen for v1: Option B with a local cap of 3 concurrent settle observers. Do not add a dependency just for this; use a tiny local mapper/helper in the orchestrator testable unit.
|
||||
|
|
@ -1419,13 +1422,13 @@ Risk:
|
|||
|
||||
Options:
|
||||
|
||||
Option A: keep current text parsing - 🎯 4 🛡️ 5 🧠 1, about 0-15 LOC
|
||||
Option A: keep current text parsing - 🎯 4 🛡️ 5 🧠 1, about 0-15 LOC
|
||||
Smallest, but fragile and contradicts the semantic seam goal.
|
||||
|
||||
Option B: pass explicit metadata while still sending the native stored text to OpenCode - 🎯 7 🛡️ 7 🧠 3, about 50-100 LOC
|
||||
Option B: pass explicit metadata while still sending the native stored text to OpenCode - 🎯 7 🛡️ 7 🧠 3, about 50-100 LOC
|
||||
Better recipient reliability, but still leaves confusing `SendMessage` wording inside the OpenCode prompt.
|
||||
|
||||
Option C: keep native inbox text only for native recipients, persist base text for OpenCode recipients, and deliver an OpenCode-native runtime message - 🎯 9 🛡️ 9 🧠 6, about 180-320 LOC with tests
|
||||
Option C: keep native inbox text only for native recipients, persist base text for OpenCode recipients, and deliver an OpenCode-native runtime message - 🎯 9 🛡️ 9 🧠 6, about 180-320 LOC with tests
|
||||
Best shape. Codex/Claude keep the existing persisted inbox text because they read inbox files directly. OpenCode inbox rows stay clean/retryable with base user text, while OpenCode receives explicit runtime delivery metadata through the adapter/relay.
|
||||
|
||||
Chosen for v1: Option C.
|
||||
|
|
@ -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');
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1569,13 +1574,13 @@ Risk:
|
|||
|
||||
Options:
|
||||
|
||||
Option A: keep fire-and-forget and add more logs - 🎯 5 🛡️ 5 🧠 1, about 10-30 LOC
|
||||
Option A: keep fire-and-forget and add more logs - 🎯 5 🛡️ 5 🧠 1, about 10-30 LOC
|
||||
This helps debugging but keeps the user-facing contract dishonest.
|
||||
|
||||
Option B: await OpenCode runtime relay for live OpenCode non-lead sends, return additive delivery status, and fix renderer action result/error propagation - 🎯 9 🛡️ 9 🧠 5, about 160-300 LOC with tests
|
||||
Option B: await OpenCode runtime relay for live OpenCode non-lead sends, return additive delivery status, and fix renderer action result/error propagation - 🎯 9 🛡️ 9 🧠 5, about 160-300 LOC with tests
|
||||
This keeps native persistence behavior unchanged, makes OpenCode failure visible, keeps retry routing in one OpenCode inbox relay path, and fixes the existing dead caller catch path that controls pending-reply cleanup.
|
||||
|
||||
Option C: add a durable OpenCode delivery queue with retries and UI retry state - 🎯 8 🛡️ 10 🧠 8, about 350-700 LOC
|
||||
Option C: add a durable OpenCode delivery queue with retries and UI retry state - 🎯 8 🛡️ 10 🧠 8, about 350-700 LOC
|
||||
This is the best long-term reliability shape, but it is too much to bundle into the semantic messaging seam unless delivery reliability remains flaky after v1.
|
||||
|
||||
Chosen for v1: Option B.
|
||||
|
|
@ -1672,7 +1677,7 @@ sendTeamMessage: async (teamName, request) => {
|
|||
});
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
};
|
||||
```
|
||||
|
||||
Update call sites so pending-reply state reflects actual delivery truth:
|
||||
|
|
@ -1730,13 +1735,13 @@ Risk examples:
|
|||
|
||||
Options:
|
||||
|
||||
Option A: only support UI direct-send to OpenCode in v1 - 🎯 5 🛡️ 5 🧠 2, about 0-40 LOC
|
||||
Option A: only support UI direct-send to OpenCode in v1 - 🎯 5 🛡️ 5 🧠 2, about 0-40 LOC
|
||||
This leaves OpenCode-to-OpenCode and system notification routes unreliable. It is not enough for a real team messaging seam.
|
||||
|
||||
Option B: add OpenCode-targeted inbox runtime relay with messageId dedupe/read marking, plus explicit unsupported-lead diagnostics - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests
|
||||
Option B: add OpenCode-targeted inbox runtime relay with messageId dedupe/read marking, plus explicit unsupported-lead diagnostics - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests
|
||||
This preserves native behavior, routes only recipients whose provider is OpenCode, and makes any persisted inbox row deliverable to live OpenCode lanes.
|
||||
|
||||
Option C: replace both native and OpenCode inbox handling with a new durable delivery queue - 🎯 8 🛡️ 10 🧠 9, about 600-1200 LOC
|
||||
Option C: replace both native and OpenCode inbox handling with a new durable delivery queue - 🎯 8 🛡️ 10 🧠 9, about 600-1200 LOC
|
||||
Architecturally clean long-term, but too large for this seam and risky with existing native watchers.
|
||||
|
||||
Chosen for v1: Option B.
|
||||
|
|
@ -1873,13 +1878,13 @@ File:
|
|||
|
||||
First decide how the required teammate-operational tool list is owned.
|
||||
|
||||
Option A: import `agent-teams-controller` into `agent_teams_orchestrator` - 🎯 5 🛡️ 6 🧠 6, about 80-160 LOC
|
||||
Option A: import `agent-teams-controller` into `agent_teams_orchestrator` - 🎯 5 🛡️ 6 🧠 6, about 80-160 LOC
|
||||
This removes list duplication, but it adds a new package dependency from the runtime/orchestrator repo into the app controller package. That is a larger architecture decision than this fix needs.
|
||||
|
||||
Option B: keep an explicit direct-MCP required list in orchestrator v1 - 🎯 8 🛡️ 8 🧠 3, about 60-140 LOC
|
||||
Option B: keep an explicit direct-MCP required list in orchestrator v1 - 🎯 8 🛡️ 8 🧠 3, about 60-140 LOC
|
||||
This matches the current repo boundary. The orchestrator only needs plain MCP names for direct `Client.listTools()` proof. Add tests that fail when critical teammate tools like `message_send`, `member_briefing`, `task_start`, or `cross_team_send` are missing.
|
||||
|
||||
Option C: generate a shared protocol contract artifact consumed by both repos - 🎯 8 🛡️ 9 🧠 7, about 250-450 LOC
|
||||
Option C: generate a shared protocol contract artifact consumed by both repos - 🎯 8 🛡️ 9 🧠 7, about 250-450 LOC
|
||||
This is the best long-term shape, but it needs generation, publishing, and CI checks. Treat it as a follow-up after v1 proves the semantic seam.
|
||||
|
||||
Chosen for v1: Option B. Do not import `agent-teams-controller` into `agent_teams_orchestrator` in this change.
|
||||
|
|
@ -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
|
||||
? []
|
||||
: [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`],
|
||||
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:
|
||||
|
|
@ -2421,62 +2354,62 @@ 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.
|
||||
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. 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.
|
||||
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.
|
||||
|
||||
3. Runtime provider inference - 🎯 7 🛡️ 8 🧠 4, about 60-120 LOC with tests
|
||||
`runtimeProvider: "opencode"` is the most reliable signal and should be sent by orchestrator. Provider metadata inference is a fallback for controller-generated messages and manual briefing calls. Native fallback must remain default when neither explicit runtimeProvider nor OpenCode metadata is present.
|
||||
3. Runtime provider inference - 🎯 7 🛡️ 8 🧠 4, about 60-120 LOC with tests
|
||||
`runtimeProvider: "opencode"` is the most reliable signal and should be sent by orchestrator. Provider metadata inference is a fallback for controller-generated messages and manual briefing calls. Native fallback must remain default when neither explicit runtimeProvider nor OpenCode metadata is present.
|
||||
|
||||
4. Production evidence freshness - 🎯 8 🛡️ 9 🧠 3, about 30-80 LOC in tests
|
||||
Old evidence that proves runtime tools only must fail production gate after this change. This is intentional. The diagnostic must explain which app MCP tools are missing so regeneration is obvious.
|
||||
4. Production evidence freshness - 🎯 8 🛡️ 9 🧠 3, about 30-80 LOC in tests
|
||||
Old evidence that proves runtime tools only must fail production gate after this change. This is intentional. The diagnostic must explain which app MCP tools are missing so regeneration is obvious.
|
||||
|
||||
5. Model compliance versus protocol availability - 🎯 6 🛡️ 8 🧠 5, about 80-180 LOC with event tests
|
||||
The protocol can make the correct tools visible and instruct the model correctly, but the model may still answer in plain text. The reliable app truth should be: runtime check-in proves the lane is alive, `message_send` proves visible user/team response, and tool-only assistant events still count as `latestAssistantMessageId` for launch liveness.
|
||||
5. Model compliance versus protocol availability - 🎯 6 🛡️ 8 🧠 5, about 80-180 LOC with event tests
|
||||
The protocol can make the correct tools visible and instruct the model correctly, but the model may still answer in plain text. The reliable app truth should be: runtime check-in proves the lane is alive, `message_send` proves visible user/team response, and tool-only assistant events still count as `latestAssistantMessageId` for launch liveness.
|
||||
|
||||
6. OpenCode send-message command durability - 🎯 7 🛡️ 7 🧠 4, about 40-120 LOC if kept direct, 140-260 LOC if moved into the state-changing bridge
|
||||
`OpenCodeReadinessBridge.sendOpenCodeTeamMessage()` currently executes `opencode.sendMessage` directly, while launch/reconcile/stop go through `OpenCodeStateChangingBridgeCommandService`. For this seam, do not expand scope unless needed: keep direct send, require adapter callers to pass `runId`, and use the runId only for identity reminder/recovery. Treat `promptAsync()` success as delivery acceptance; post-send reconcile failure is a warning, not a false delivery failure. If stale-send bugs continue, promote `opencode.sendMessage` into the state-changing command service as a separate reliability pass.
|
||||
6. OpenCode send-message command durability - 🎯 7 🛡️ 7 🧠 4, about 40-120 LOC if kept direct, 140-260 LOC if moved into the state-changing bridge
|
||||
`OpenCodeReadinessBridge.sendOpenCodeTeamMessage()` currently executes `opencode.sendMessage` directly, while launch/reconcile/stop go through `OpenCodeStateChangingBridgeCommandService`. For this seam, do not expand scope unless needed: keep direct send, require adapter callers to pass `runId`, and use the runId only for identity reminder/recovery. Treat `promptAsync()` success as delivery acceptance; post-send reconcile failure is a warning, not a false delivery failure. If stale-send bugs continue, promote `opencode.sendMessage` into the state-changing command service as a separate reliability pass.
|
||||
|
||||
7. Cross-team transport split - 🎯 8 🛡️ 9 🧠 4, about 50-120 LOC in prompt helper/tests
|
||||
OpenCode needs two visible messaging transports: `message_send` for local user/lead/member messages, and `cross_team_send` for remote teams. Collapsing both into `message_send` would resurrect the exact bug existing prompts warn about: treating `cross_team_send` as a recipient. The helper should expose both phrases/examples, but implementation remains narrow because it only affects wording and tests.
|
||||
7. Cross-team transport split - 🎯 8 🛡️ 9 🧠 4, about 50-120 LOC in prompt helper/tests
|
||||
OpenCode needs two visible messaging transports: `message_send` for local user/lead/member messages, and `cross_team_send` for remote teams. Collapsing both into `message_send` would resurrect the exact bug existing prompts warn about: treating `cross_team_send` as a recipient. The helper should expose both phrases/examples, but implementation remains narrow because it only affects wording and tests.
|
||||
|
||||
8. Direct-proof output shape - 🎯 8 🛡️ 9 🧠 4, about 40-100 LOC in orchestrator/tests
|
||||
The orchestrator direct MCP proof must match plain names from `Client.listTools()`, but `readiness.evidence.observedMcpTools` should remain canonical ids because production evidence consumes it. This is a boundary-shape risk, not a model behavior risk. Add tests that plain direct names pass matching while public bridge evidence contains `agent-teams_message_send`.
|
||||
8. Direct-proof output shape - 🎯 8 🛡️ 9 🧠 4, about 40-100 LOC in orchestrator/tests
|
||||
The orchestrator direct MCP proof must match plain names from `Client.listTools()`, but `readiness.evidence.observedMcpTools` should remain canonical ids because production evidence consumes it. This is a boundary-shape risk, not a model behavior risk. Add tests that plain direct names pass matching while public bridge evidence contains `agent-teams_message_send`.
|
||||
|
||||
8.1 Plain tool-name false positives - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests
|
||||
8.1 Plain tool-name false positives - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests
|
||||
Alias parsing must accept plain `message_send` for OpenCode direct MCP proof/capture, but a plain name alone is not enough in arbitrary transcripts. For capture/log paths, require Agent Teams payload shape and current team match before treating a plain short name as our tool. Canonical/prefixed names remain accepted directly.
|
||||
|
||||
9. Durable run id consumption - 🎯 9 🛡️ 9 🧠 5, about 90-180 LOC with tests
|
||||
`activeRunId` already lives in the lane-scoped `RuntimeStoreManifest`; `lanes.json` only proves lane state. Bootstrap evidence acceptance, runtime delivery journaling, and message delivery must read the manifest when in-memory run maps are empty after app restart. Without this, OpenCode lanes can be active in `lanes.json` but still reject check-in or send messages without identity recovery. Do not add a duplicate run id field to `lanes.json` in v1.
|
||||
9. Durable run id consumption - 🎯 9 🛡️ 9 🧠 5, about 90-180 LOC with tests
|
||||
`activeRunId` already lives in the lane-scoped `RuntimeStoreManifest`; `lanes.json` only proves lane state. Bootstrap evidence acceptance, runtime delivery journaling, and message delivery must read the manifest when in-memory run maps are empty after app restart. Without this, OpenCode lanes can be active in `lanes.json` but still reject check-in or send messages without identity recovery. Do not add a duplicate run id field to `lanes.json` in v1.
|
||||
|
||||
10. Cross-team taskRefs mismatch - 🎯 7 🛡️ 8 🧠 4, about 0-25 LOC if forbidden in v1, 70-150 LOC if wired end-to-end
|
||||
Shared types already include `taskRefs` for cross-team messages, but `cross_team_send` schema/controller do not persist them. The semantic helper must not generate unsupported fields. Either explicitly forbid cross-team taskRefs in v1 helper examples or wire schema/storage/tests now.
|
||||
10. Cross-team taskRefs mismatch - 🎯 7 🛡️ 8 🧠 4, about 0-25 LOC if forbidden in v1, 70-150 LOC if wired end-to-end
|
||||
Shared types already include `taskRefs` for cross-team messages, but `cross_team_send` schema/controller do not persist them. The semantic helper must not generate unsupported fields. Either explicitly forbid cross-team taskRefs in v1 helper examples or wire schema/storage/tests now.
|
||||
|
||||
11. OpenCode direct-message metadata - 🎯 9 🛡️ 9 🧠 5, about 120-220 LOC with tests
|
||||
OpenCode runtime delivery should not parse native `SendMessage` prompt text to discover the reply recipient. Pass `replyRecipient`, `actionMode`, and `taskRefs` as explicit adapter metadata, and build a separate OpenCode-native delivery prompt. This lowers model confusion and removes regex coupling to `buildMessageDeliveryText()`.
|
||||
11. OpenCode direct-message metadata - 🎯 9 🛡️ 9 🧠 5, about 120-220 LOC with tests
|
||||
OpenCode runtime delivery should not parse native `SendMessage` prompt text to discover the reply recipient. Pass `replyRecipient`, `actionMode`, and `taskRefs` as explicit adapter metadata, and build a separate OpenCode-native delivery prompt. This lowers model confusion and removes regex coupling to `buildMessageDeliveryText()`.
|
||||
|
||||
12. Runtime delivery event adapter shape - 🎯 9 🛡️ 9 🧠 3, about 20-60 LOC in tests
|
||||
`RuntimeDeliveryService` uses a local `data.detail` envelope, but the app uses `TeamChangeEvent.detail`. The existing adapter maps it correctly. Test this so a future refactor does not bypass the adapter and make OpenCode replies visible in some UI paths while relay/notification/detail-sensitive paths silently miss the change.
|
||||
12. Runtime delivery event adapter shape - 🎯 9 🛡️ 9 🧠 3, about 20-60 LOC in tests
|
||||
`RuntimeDeliveryService` uses a local `data.detail` envelope, but the app uses `TeamChangeEvent.detail`. The existing adapter maps it correctly. Test this so a future refactor does not bypass the adapter and make OpenCode replies visible in some UI paths while relay/notification/detail-sensitive paths silently miss the change.
|
||||
|
||||
13. User-directed `message_send` sender identity - 🎯 9 🛡️ 9 🧠 3, about 35-90 LOC with tests
|
||||
The protocol cannot rely only on prompt text saying "include from". If `from` is absent for `to: "user"`, the controller currently creates a durable user-to-user row. Add a narrow guard that rejects missing/invalid sender only for user-directed MCP messages, while keeping legacy user-to-member `message_send` defaults intact.
|
||||
13. User-directed `message_send` sender identity - 🎯 9 🛡️ 9 🧠 3, about 35-90 LOC with tests
|
||||
The protocol cannot rely only on prompt text saying "include from". If `from` is absent for `to: "user"`, the controller currently creates a durable user-to-user row. Add a narrow guard that rejects missing/invalid sender only for user-directed MCP messages, while keeping legacy user-to-member `message_send` defaults intact.
|
||||
|
||||
14. OpenCode direct-message delivery acknowledgement - 🎯 9 🛡️ 9 🧠 5, about 130-260 LOC with tests
|
||||
The send-message IPC path currently treats inbox persistence as success and starts OpenCode runtime delivery asynchronously. That is okay for native teammates because they watch/read inbox files, but not for OpenCode lanes. Add an additive runtime delivery result and fix the renderer store action to return/rethrow, so UI can distinguish "message saved" from "OpenCode runtime actually received the prompt" and pending replies do not hang on hidden failures.
|
||||
14. OpenCode direct-message delivery acknowledgement - 🎯 9 🛡️ 9 🧠 5, about 130-260 LOC with tests
|
||||
The send-message IPC path currently treats inbox persistence as success and starts OpenCode runtime delivery asynchronously. That is okay for native teammates because they watch/read inbox files, but not for OpenCode lanes. Add an additive runtime delivery result and fix the renderer store action to return/rethrow, so UI can distinguish "message saved" from "OpenCode runtime actually received the prompt" and pending replies do not hang on hidden failures.
|
||||
|
||||
15. Runtime delivery tool ambiguity - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests
|
||||
`runtime_deliver_message` can write real destinations, so it is not safe to rely on the name alone and hope the model chooses `message_send`. V1 should keep it available for runtime evidence but make descriptions/prompts explicit that normal visible replies use `message_send`, while runtime delivery is a low-level idempotent path only when explicitly requested.
|
||||
15. Runtime delivery tool ambiguity - 🎯 8 🛡️ 8 🧠 3, about 25-70 LOC with tests
|
||||
`runtime_deliver_message` can write real destinations, so it is not safe to rely on the name alone and hope the model chooses `message_send`. V1 should keep it available for runtime evidence but make descriptions/prompts explicit that normal visible replies use `message_send`, while runtime delivery is a low-level idempotent path only when explicitly requested.
|
||||
|
||||
16. OpenCode-targeted inbox relay - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests
|
||||
The app currently has special relay for native lead inboxes and native teammate file-watch behavior, but OpenCode teammates do not watch `inboxes/<member>.json`. Any plan that only fixes UI direct-send still leaves OpenCode-to-OpenCode and system notification routes unreliable. Add recipient-provider-aware runtime relay with at-least-once semantics, read-flag commit, duplicate-event dedupe, and explicit unsupported OpenCode lead diagnostics. Do not reuse native `relayMemberInboxMessages()`.
|
||||
16. OpenCode-targeted inbox relay - 🎯 9 🛡️ 9 🧠 6, about 240-440 LOC with tests
|
||||
The app currently has special relay for native lead inboxes and native teammate file-watch behavior, but OpenCode teammates do not watch `inboxes/<member>.json`. Any plan that only fixes UI direct-send still leaves OpenCode-to-OpenCode and system notification routes unreliable. Add recipient-provider-aware runtime relay with at-least-once semantics, read-flag commit, duplicate-event dedupe, and explicit unsupported OpenCode lead diagnostics. Do not reuse native `relayMemberInboxMessages()`.
|
||||
|
||||
17. `message_send` recipient canonicalization - 🎯 9 🛡️ 9 🧠 4, about 70-150 LOC with tests
|
||||
Raw recipient names currently become inbox filenames. This is fragile because prompts and tests use lead aliases like `team-lead` while teams can have a custom lead name. Resolve `to` and `from` against configured members before persistence, with `user` as the only special local destination and cross-team tool names rejected clearly.
|
||||
17. `message_send` recipient canonicalization - 🎯 9 🛡️ 9 🧠 4, about 70-150 LOC with tests
|
||||
Raw recipient names currently become inbox filenames. This is fragile because prompts and tests use lead aliases like `team-lead` while teams can have a custom lead name. Resolve `to` and `from` against configured members before persistence, with `user` as the only special local destination and cross-team tool names rejected clearly.
|
||||
|
||||
18. OpenCode lead runtime session gap - 🎯 8 🛡️ 9 🧠 5, about 60-140 LOC for v1 diagnostics, 300-700 LOC if adding a real lead lane
|
||||
The app-side OpenCode adapter passes `leadPrompt`, but the orchestrator launch handler currently creates sessions from `body.members` only. `relayLeadInboxMessages()` also requires native `run.child`. V1 must not pretend pure OpenCode lead inbox delivery works. Either route to an existing stored `team-lead` OpenCode session if one is later introduced, or leave rows unread with an explicit diagnostic. Creating a real OpenCode lead lane is a separate feature, not a hidden side effect of this messaging seam.
|
||||
18. OpenCode lead runtime session gap - 🎯 8 🛡️ 9 🧠 5, about 60-140 LOC for v1 diagnostics, 300-700 LOC if adding a real lead lane
|
||||
The app-side OpenCode adapter passes `leadPrompt`, but the orchestrator launch handler currently creates sessions from `body.members` only. `relayLeadInboxMessages()` also requires native `run.child`. V1 must not pretend pure OpenCode lead inbox delivery works. Either route to an existing stored `team-lead` OpenCode session if one is later introduced, or leave rows unread with an explicit diagnostic. Creating a real OpenCode lead lane is a separate feature, not a hidden side effect of this messaging seam.
|
||||
|
||||
## Tests
|
||||
|
||||
|
|
@ -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 }) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
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,17 +443,19 @@ 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, {
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -448,20 +480,22 @@ 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, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -488,20 +522,22 @@ 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, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -512,17 +548,19 @@ 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(
|
||||
taskId,
|
||||
value === 'clear' ? null : value
|
||||
taskId,
|
||||
value === 'clear' ? null : value
|
||||
) 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,16 +622,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
memberName: z.string().min(1),
|
||||
runtimeProvider: z.enum(['native', 'opencode']).optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, {
|
||||
...(runtimeProvider ? { runtimeProvider } : {}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName, {
|
||||
...(runtimeProvider ? { runtimeProvider } : {}),
|
||||
}),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -585,13 +644,16 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName }) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.taskBriefing(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,9 +109,10 @@ function getPlatformAssetKey() {
|
|||
}
|
||||
|
||||
function getReleaseAssetUrl(runtimeLock, asset) {
|
||||
const releaseTag = typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
|
||||
? runtimeLock.releaseTag.trim()
|
||||
: runtimeLock.sourceRef;
|
||||
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',
|
||||
{
|
||||
archived: false,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
liveRequestTimeoutMs
|
||||
),
|
||||
session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: true,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
archivedRequestTimeoutMs
|
||||
),
|
||||
]);
|
||||
const live = await this.#requestThreadListSegment(session, {
|
||||
archived: false,
|
||||
limit: options.limit,
|
||||
timeoutMs: liveRequestTimeoutMs,
|
||||
});
|
||||
const archived = await this.#requestThreadListSegment(session, {
|
||||
archived: true,
|
||||
limit: options.limit,
|
||||
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,13 +382,13 @@ function normalizeBootstrapMemberState(
|
|||
? raw.failureReason.trim()
|
||||
: 'deterministic bootstrap failed',
|
||||
]
|
||||
: runtimeAlive
|
||||
? bootstrapConfirmed
|
||||
? ['late heartbeat received']
|
||||
: ['runtime alive', 'waiting for teammate check-in']
|
||||
: agentToolAccepted
|
||||
? ['spawn accepted']
|
||||
: undefined,
|
||||
: bootstrapConfirmed
|
||||
? ['late heartbeat received']
|
||||
: 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,34 +154,22 @@ 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,
|
||||
modelId,
|
||||
capabilities,
|
||||
supportLevel: support.supportLevel,
|
||||
missing: support.diagnostics,
|
||||
diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics),
|
||||
});
|
||||
}
|
||||
return readiness({
|
||||
state: mapSupportLevelToReadinessState(support.supportLevel),
|
||||
inventory,
|
||||
modelId,
|
||||
capabilities,
|
||||
supportLevel: support.supportLevel,
|
||||
missing: support.diagnostics,
|
||||
diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics),
|
||||
});
|
||||
}
|
||||
|
||||
const runtimeStoreReadiness = await this.runtimeStores.check({
|
||||
|
|
@ -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,28 +750,66 @@ function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefin
|
|||
.map((i) => ({ ...i }));
|
||||
}
|
||||
|
||||
/** 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';
|
||||
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';
|
||||
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 (t === 'status_changed' && e.to === 'in_progress') {
|
||||
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 | null {
|
||||
if (!Array.isArray(events) || events.length === 0) return null;
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
const reviewState = eventReviewState(e);
|
||||
if (reviewState) {
|
||||
return reviewState;
|
||||
}
|
||||
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 null;
|
||||
}
|
||||
|
||||
function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
||||
if (!Array.isArray(parsed.comments)) return undefined;
|
||||
return (parsed.comments as unknown[])
|
||||
|
|
@ -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,21 +433,117 @@ export const MemberCard = ({
|
|||
{launchBadgeLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
) : spawnStatus === 'error' ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
|
||||
>
|
||||
{displayPresenceLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
|
||||
</Tooltip>
|
||||
) : showFailedLaunchBadge ? (
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
|
||||
>
|
||||
{displayPresenceLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</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,18 +258,33 @@ export const MemberHoverCard = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Open profile button */}
|
||||
<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)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMemberProfile(member.name);
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
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 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);
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
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