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
|
- [ ] Run terminal commands
|
||||||
- [ ] Monitor agents processes/stats
|
- [ ] Monitor agents processes/stats
|
||||||
- [ ] Reusable agents with SOUL.md
|
- [ ] Reusable agents with SOUL.md
|
||||||
|
- [ ] Сommunicate via messenger
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -48,25 +48,19 @@ function normalizeMetaMembers(rawMembers) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveTargetLead(paths, config) {
|
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) {
|
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();
|
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');
|
const metaPath = path.join(paths.teamDir, 'members.meta.json');
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
||||||
const members = normalizeMetaMembers(raw && raw.members);
|
const members = normalizeMetaMembers(raw && raw.members);
|
||||||
if (members.length > 0) {
|
if (members.length > 0) {
|
||||||
const metaLead = members.find(
|
const metaLead = members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
|
||||||
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
|
|
||||||
);
|
|
||||||
if (metaLead && metaLead.name) return metaLead.name;
|
if (metaLead && metaLead.name) return metaLead.name;
|
||||||
return members[0].name;
|
return members[0].name;
|
||||||
}
|
}
|
||||||
|
|
@ -74,13 +68,8 @@ function resolveTargetLead(paths, config) {
|
||||||
/* ENOENT or parse error */
|
/* ENOENT or parse error */
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. role-based (legacy compat)
|
// 3. First configured member
|
||||||
if (config && config.members && config.members.length) {
|
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();
|
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) {
|
function sendCrossTeamMessage(context, flags) {
|
||||||
const fromTeam = context.teamName;
|
const fromTeam = context.teamName;
|
||||||
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
|
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 =
|
const replyToConversationId =
|
||||||
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
|
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
|
||||||
const conversationId =
|
const conversationId =
|
||||||
|
|
@ -181,6 +170,10 @@ function sendCrossTeamMessage(context, flags) {
|
||||||
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
|
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
|
||||||
throw new Error(`Invalid fromTeam: ${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)) {
|
if (!TEAM_NAME_PATTERN.test(toTeam)) {
|
||||||
throw new Error(`Invalid toTeam: ${toTeam}`);
|
throw new Error(`Invalid toTeam: ${toTeam}`);
|
||||||
}
|
}
|
||||||
|
|
@ -190,14 +183,11 @@ function sendCrossTeamMessage(context, flags) {
|
||||||
if (!text || text.trim().length === 0) {
|
if (!text || text.trim().length === 0) {
|
||||||
throw new Error('Message text is required');
|
throw new Error('Message text is required');
|
||||||
}
|
}
|
||||||
const fromMember = runtimeHelpers.assertExplicitTeamMemberName(
|
const fromMember = rawFromMember
|
||||||
context.paths,
|
? runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFromMember, 'cross-team sender', {
|
||||||
rawFromMember,
|
|
||||||
'fromMember',
|
|
||||||
{
|
|
||||||
allowLeadAliases: true,
|
allowLeadAliases: true,
|
||||||
}
|
})
|
||||||
);
|
: runtimeHelpers.inferLeadName(context.paths);
|
||||||
|
|
||||||
// Target context + config
|
// Target context + config
|
||||||
const targetContext = createTargetContext(context, toTeam);
|
const targetContext = createTargetContext(context, toTeam);
|
||||||
|
|
|
||||||
|
|
@ -101,12 +101,12 @@ function listReviewers(context) {
|
||||||
|
|
||||||
function addReviewer(context, reviewer) {
|
function addReviewer(context, reviewer) {
|
||||||
return withTeamBoardLock(context.paths, () => {
|
return withTeamBoardLock(context.paths, () => {
|
||||||
runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
|
const resolvedReviewer = runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
|
||||||
allowLeadAliases: true,
|
allowLeadAliases: true,
|
||||||
});
|
});
|
||||||
const state = getKanbanState(context);
|
const state = getKanbanState(context);
|
||||||
const next = new Set(state.reviewers);
|
const next = new Set(state.reviewers);
|
||||||
next.add(String(reviewer));
|
next.add(String(resolvedReviewer));
|
||||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||||
...state,
|
...state,
|
||||||
reviewers: [...next],
|
reviewers: [...next],
|
||||||
|
|
@ -118,7 +118,13 @@ function addReviewer(context, reviewer) {
|
||||||
function removeReviewer(context, reviewer) {
|
function removeReviewer(context, reviewer) {
|
||||||
return withTeamBoardLock(context.paths, () => {
|
return withTeamBoardLock(context.paths, () => {
|
||||||
const state = getKanbanState(context);
|
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, {
|
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||||
...state,
|
...state,
|
||||||
reviewers: next,
|
reviewers: next,
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,9 @@ function normalizeActorKey(value) {
|
||||||
function resolveKnownActorName(context, value, label) {
|
function resolveKnownActorName(context, value, label) {
|
||||||
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||||
if (!actor) return null;
|
if (!actor) return null;
|
||||||
runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
|
return runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
|
||||||
allowLeadAliases: true,
|
allowLeadAliases: true,
|
||||||
});
|
});
|
||||||
return actor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryResolveKnownActorName(context, value, label) {
|
function tryResolveKnownActorName(context, value, label) {
|
||||||
|
|
@ -301,9 +300,10 @@ function requestReview(context, taskId, flags = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextFrom =
|
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 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);
|
const prevReviewState = getEffectiveReviewState(context, currentTask);
|
||||||
if (prevReviewState === 'approved') {
|
if (prevReviewState === 'approved') {
|
||||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);
|
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;
|
return historyState;
|
||||||
}
|
}
|
||||||
|
|
||||||
const persisted = normalizeReviewState(task && task.reviewState);
|
const status = typeof task?.status === 'string' ? task.status.trim() : '';
|
||||||
if (persisted !== 'none') {
|
const normalizeFallback = (state, source) => {
|
||||||
|
const normalized = normalizeReviewState(state);
|
||||||
|
if (normalized === 'none') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'in_progress' || status === 'deleted') {
|
||||||
return {
|
return {
|
||||||
state: persisted,
|
state: 'none',
|
||||||
source: 'task_review_state',
|
source: `${source}_status_reset`,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
|
if (status === 'pending') {
|
||||||
return {
|
return normalized === 'needsFix'
|
||||||
state: normalizeReviewState(kanbanEntry.column),
|
? { state: 'needsFix', source: `${source}_pending_needs_fix` }
|
||||||
source: 'kanban_column',
|
: { state: 'none', source: `${source}_pending_reset` };
|
||||||
|
}
|
||||||
|
|
||||||
|
if (status === 'completed') {
|
||||||
|
return normalized === 'review' || normalized === 'approved'
|
||||||
|
? { state: normalized, source }
|
||||||
|
: { state: 'none', source: `${source}_completed_reset` };
|
||||||
|
}
|
||||||
|
|
||||||
|
return { state: normalized, source };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const persisted = normalizeReviewState(task && task.reviewState);
|
||||||
|
const persistedFallback = normalizeFallback(persisted, 'task_review_state');
|
||||||
|
if (persistedFallback) {
|
||||||
|
return persistedFallback;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
|
||||||
|
const kanbanFallback = normalizeFallback(kanbanEntry.column, 'kanban_column');
|
||||||
|
if (kanbanFallback) {
|
||||||
|
return kanbanFallback;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const crypto = require('crypto');
|
||||||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||||
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
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([
|
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||||
'cross_team_send',
|
'cross_team_send',
|
||||||
'cross_team_list_targets',
|
'cross_team_list_targets',
|
||||||
|
|
@ -134,7 +135,7 @@ function isCanonicalLeadMember(member) {
|
||||||
const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : '';
|
const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : '';
|
||||||
const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : '';
|
const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : '';
|
||||||
return (
|
return (
|
||||||
agentType === 'team-lead' ||
|
LEAD_AGENT_TYPES.has(agentType) ||
|
||||||
name === 'team-lead' ||
|
name === 'team-lead' ||
|
||||||
role === 'team-lead' ||
|
role === 'team-lead' ||
|
||||||
role === 'team lead' ||
|
role === 'team lead' ||
|
||||||
|
|
@ -179,12 +180,10 @@ function inferLeadName(paths) {
|
||||||
const resolved = resolveTeamMembers(paths);
|
const resolved = resolveTeamMembers(paths);
|
||||||
const members = resolved.members || [];
|
const members = resolved.members || [];
|
||||||
const lead =
|
const lead =
|
||||||
members.find(
|
members.find((member) => {
|
||||||
(member) =>
|
const agentType = typeof member?.agentType === 'string' ? member.agentType.trim().toLowerCase() : '';
|
||||||
member &&
|
return LEAD_AGENT_TYPES.has(agentType);
|
||||||
typeof member.agentType === 'string' &&
|
}) ||
|
||||||
member.agentType.trim().toLowerCase() === 'team-lead'
|
|
||||||
) ||
|
|
||||||
members.find((member) => String((member && member.name) || '').trim().toLowerCase() === 'team-lead') ||
|
members.find((member) => String((member && member.name) || '').trim().toLowerCase() === 'team-lead') ||
|
||||||
members.find(
|
members.find(
|
||||||
(member) => {
|
(member) => {
|
||||||
|
|
|
||||||
|
|
@ -215,19 +215,23 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTask(context, input) {
|
function createTask(context, input) {
|
||||||
|
let taskInput = input;
|
||||||
if (input && typeof input.owner === 'string' && input.owner.trim()) {
|
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));
|
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, taskInput));
|
||||||
if (input && input.notifyOwner !== false) {
|
if (taskInput && taskInput.notifyOwner !== false) {
|
||||||
maybeNotifyAssignedOwner(context, task, {
|
maybeNotifyAssignedOwner(context, task, {
|
||||||
description: input.description,
|
description: taskInput.description,
|
||||||
prompt: input.prompt,
|
prompt: taskInput.prompt,
|
||||||
taskRefs: [
|
taskRefs: [
|
||||||
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
|
...(Array.isArray(taskInput.descriptionTaskRefs) ? taskInput.descriptionTaskRefs : []),
|
||||||
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
|
...(Array.isArray(taskInput.promptTaskRefs) ? taskInput.promptTaskRefs : []),
|
||||||
],
|
],
|
||||||
from: input.from,
|
from: taskInput.from,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return task;
|
return task;
|
||||||
|
|
@ -410,6 +414,10 @@ function softDeleteTask(context, taskId, actor) {
|
||||||
|
|
||||||
function restoreTask(context, taskId, actor) {
|
function restoreTask(context, taskId, actor) {
|
||||||
return withTeamBoardLock(context.paths, () => {
|
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');
|
let task = taskStore.setTaskStatus(context.paths, taskId, 'pending', actor || 'user');
|
||||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||||
if (hasKanbanReference(state, task.id)) {
|
if (hasKanbanReference(state, task.id)) {
|
||||||
|
|
@ -431,7 +439,7 @@ function setTaskOwner(context, taskId, owner) {
|
||||||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||||
const nextOwner = isClearOwnerValue(owner)
|
const nextOwner = isClearOwnerValue(owner)
|
||||||
? owner
|
? owner
|
||||||
: (assertKnownTaskActor(context, owner, 'task owner'), owner);
|
: assertKnownTaskActor(context, owner, 'task owner');
|
||||||
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
|
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
|
||||||
return {
|
return {
|
||||||
previousTask: before,
|
previousTask: before,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const AGENT_TEAMS_TASK_TOOL_NAMES = [
|
||||||
'task_get_comment',
|
'task_get_comment',
|
||||||
'task_link',
|
'task_link',
|
||||||
'task_list',
|
'task_list',
|
||||||
|
'task_restore',
|
||||||
'task_set_clarification',
|
'task_set_clarification',
|
||||||
'task_set_owner',
|
'task_set_owner',
|
||||||
'task_set_status',
|
'task_set_status',
|
||||||
|
|
|
||||||
|
|
@ -447,6 +447,31 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(briefing).toContain('Counters: actionable=4, awareness=3');
|
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', () => {
|
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
@ -1281,6 +1306,82 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(leadBriefing).not.toContain(`#${task.displayId}`);
|
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 () => {
|
it('rejects task_briefing for unknown members', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
@ -1472,6 +1573,22 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(restored.reviewState).toBe('none');
|
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', () => {
|
it('uses actual kanban overlay for kanbanColumn inventory filters', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
|
||||||
|
|
@ -368,6 +368,108 @@ describe('crossTeam module', () => {
|
||||||
expect(fs.existsSync(inboxPath)).toBe(true);
|
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', () => {
|
it('resolves lead by name fallback', () => {
|
||||||
const claudeDir = makeClaudeDir({
|
const claudeDir = makeClaudeDir({
|
||||||
'team-a': {
|
'team-a': {
|
||||||
|
|
|
||||||
2281
docs/team-management/member-liveness-hardening-plan.md
Normal file
2281
docs/team-management/member-liveness-hardening-plan.md
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -54,11 +54,9 @@ This section records the higher-risk places that were checked after the first dr
|
||||||
- The required app-tool proof must cover all teammate-operational tools that `member_briefing` can instruct, not just `message_send` and four task tools.
|
- 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.
|
- `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`.
|
- 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.
|
- `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.
|
- 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.
|
||||||
- `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.
|
|
||||||
- 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.
|
- 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.
|
- `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.
|
- `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/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/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/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/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/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/OpenCodeTaskLogStreamSource.ts`
|
||||||
- `/Users/belief/dev/projects/claude/claude_team/src/main/services/team/taskLogs/stream/BoardTaskLogStreamService.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:
|
`agent_teams_orchestrator` files:
|
||||||
|
|
||||||
|
|
@ -222,7 +217,9 @@ Example:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
function normalizeRuntimeProvider(value) {
|
function normalizeRuntimeProvider(value) {
|
||||||
const normalized = String(value || '').trim().toLowerCase();
|
const normalized = String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
return normalized === 'opencode' ? 'opencode' : 'native';
|
return normalized === 'opencode' ? 'opencode' : 'native';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -267,7 +264,9 @@ function createMemberMessagingProtocol(runtimeProvider) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function isOpenCodeMember(member) {
|
function isOpenCodeMember(member) {
|
||||||
const provider = String(member?.providerId || member?.provider || '').trim().toLowerCase();
|
const provider = String(member?.providerId || member?.provider || '')
|
||||||
|
.trim()
|
||||||
|
.toLowerCase();
|
||||||
return provider === 'opencode';
|
return provider === 'opencode';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -302,9 +301,7 @@ Edit pattern:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
function copyTrimmedString(member, key) {
|
function copyTrimmedString(member, key) {
|
||||||
return typeof member[key] === 'string' && member[key].trim()
|
return typeof member[key] === 'string' && member[key].trim() ? { [key]: member[key].trim() } : {};
|
||||||
? { [key]: member[key].trim() }
|
|
||||||
: {};
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -314,9 +311,15 @@ Then preserve fields:
|
||||||
return {
|
return {
|
||||||
name,
|
name,
|
||||||
...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}),
|
...(typeof member.role === 'string' && member.role.trim() ? { role: member.role.trim() } : {}),
|
||||||
...(typeof member.workflow === 'string' && member.workflow.trim() ? { workflow: member.workflow.trim() } : {}),
|
...(typeof member.workflow === 'string' && member.workflow.trim()
|
||||||
...(typeof member.agentType === 'string' && member.agentType.trim() ? { agentType: member.agentType.trim() } : {}),
|
? { workflow: member.workflow.trim() }
|
||||||
...(typeof member.color === 'string' && member.color.trim() ? { color: member.color.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() } : {}),
|
...(typeof member.cwd === 'string' && member.cwd.trim() ? { cwd: member.cwd.trim() } : {}),
|
||||||
...copyTrimmedString(member, 'providerId'),
|
...copyTrimmedString(member, 'providerId'),
|
||||||
...copyTrimmedString(member, 'providerBackendId'),
|
...copyTrimmedString(member, 'providerBackendId'),
|
||||||
|
|
@ -398,7 +401,8 @@ Inside the function:
|
||||||
|
|
||||||
```js
|
```js
|
||||||
const explicitRuntimeProvider = options.runtimeProvider;
|
const explicitRuntimeProvider = options.runtimeProvider;
|
||||||
const inferredRuntimeProvider = explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native');
|
const inferredRuntimeProvider =
|
||||||
|
explicitRuntimeProvider || (isOpenCodeMember(effectiveMember) ? 'opencode' : 'native');
|
||||||
const messagingProtocol = createMemberMessagingProtocol(inferredRuntimeProvider);
|
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:
|
Instead, make tool descriptions and OpenCode prompts explicitly route normal replies:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
description:
|
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.';
|
||||||
'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
|
```ts
|
||||||
description:
|
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.';
|
||||||
'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:
|
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.');
|
throw new Error('message_send cannot target another team. Use cross_team_send with toTeam.');
|
||||||
}
|
}
|
||||||
if (!resolvedTo && runtimeHelpers.looksLikeCrossTeamToolRecipient?.(rawTo)) {
|
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) {
|
if (!resolvedTo) {
|
||||||
throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`);
|
throw new Error(`Unknown to: ${rawTo}. Use a configured team member name.`);
|
||||||
|
|
@ -903,7 +907,7 @@ Current risk:
|
||||||
`TeamProvisioningService.captureSendMessages()` recognizes only:
|
`TeamProvisioningService.captureSendMessages()` recognizes only:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
part.name === 'mcp__agent-teams__message_send'
|
part.name === 'mcp__agent-teams__message_send';
|
||||||
```
|
```
|
||||||
|
|
||||||
But OpenCode and MCP tooling can expose names as:
|
But OpenCode and MCP tooling can expose names as:
|
||||||
|
|
@ -957,8 +961,7 @@ export function isAgentTeamsToolUse(input: {
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasKnownPrefix =
|
const hasKnownPrefix =
|
||||||
rawName !== canonical ||
|
rawName !== canonical || AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix));
|
||||||
AGENT_TEAMS_PREFIXES.some((prefix) => rawName.startsWith(prefix));
|
|
||||||
if (hasKnownPrefix) {
|
if (hasKnownPrefix) {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -1078,23 +1081,23 @@ Add a helper:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
function buildOpenCodeRuntimeIdentityBlock(input: {
|
function buildOpenCodeRuntimeIdentityBlock(input: {
|
||||||
teamName: string
|
teamName: string;
|
||||||
memberName: string
|
memberName: string;
|
||||||
runId: string
|
runId: string;
|
||||||
runtimeSessionId: string
|
runtimeSessionId: string;
|
||||||
}): string {
|
}): string {
|
||||||
const checkinPayload = {
|
const checkinPayload = {
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
runId: input.runId,
|
runId: input.runId,
|
||||||
memberName: input.memberName,
|
memberName: input.memberName,
|
||||||
runtimeSessionId: input.runtimeSessionId,
|
runtimeSessionId: input.runtimeSessionId,
|
||||||
}
|
};
|
||||||
|
|
||||||
const briefingPayload = {
|
const briefingPayload = {
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
memberName: input.memberName,
|
memberName: input.memberName,
|
||||||
runtimeProvider: 'opencode',
|
runtimeProvider: 'opencode',
|
||||||
}
|
};
|
||||||
|
|
||||||
return [
|
return [
|
||||||
'<opencode_runtime_identity>',
|
'<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)}`,
|
`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.',
|
'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>',
|
'</opencode_runtime_identity>',
|
||||||
].join('\n')
|
].join('\n');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1117,12 +1120,12 @@ const runtimeIdentityBlock = buildOpenCodeRuntimeIdentityBlock({
|
||||||
memberName: name,
|
memberName: name,
|
||||||
runId,
|
runId,
|
||||||
runtimeSessionId: record.opencodeSessionId,
|
runtimeSessionId: record.opencodeSessionId,
|
||||||
})
|
});
|
||||||
|
|
||||||
await openCodeSessionBridge.promptAsync(record, {
|
await openCodeSessionBridge.promptAsync(record, {
|
||||||
text: `${runtimeIdentityBlock}\n\n${prompt}`,
|
text: `${runtimeIdentityBlock}\n\n${prompt}`,
|
||||||
agent: 'teammate',
|
agent: 'teammate',
|
||||||
})
|
});
|
||||||
```
|
```
|
||||||
|
|
||||||
Then add a bounded launch-settle helper before mapping the member as final `created`/`confirmed_alive`.
|
Then add a bounded launch-settle helper before mapping the member as final `created`/`confirmed_alive`.
|
||||||
|
|
@ -1512,7 +1515,9 @@ function buildOpenCodeRuntimeMessageText(input: OpenCodeTeamRuntimeMessageInput)
|
||||||
'</opencode_app_message_delivery>',
|
'</opencode_app_message_delivery>',
|
||||||
'',
|
'',
|
||||||
input.text,
|
input.text,
|
||||||
].filter((line): line is string => line !== null).join('\n');
|
]
|
||||||
|
.filter((line): line is string => line !== null)
|
||||||
|
.join('\n');
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1672,7 +1677,7 @@ sendTeamMessage: async (teamName, request) => {
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Update call sites so pending-reply state reflects actual delivery truth:
|
Update call sites so pending-reply state reflects actual delivery truth:
|
||||||
|
|
@ -1904,7 +1909,7 @@ const REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS = [
|
||||||
'runtime_deliver_message',
|
'runtime_deliver_message',
|
||||||
'runtime_task_event',
|
'runtime_task_event',
|
||||||
'runtime_heartbeat',
|
'runtime_heartbeat',
|
||||||
] as const
|
] as const;
|
||||||
```
|
```
|
||||||
|
|
||||||
Change to route-specific direct MCP names.
|
Change to route-specific direct MCP names.
|
||||||
|
|
@ -1913,10 +1918,10 @@ Important:
|
||||||
|
|
||||||
- `Client.listTools()` returns plain names such as `message_send`.
|
- `Client.listTools()` returns plain names such as `message_send`.
|
||||||
- Do not prefix direct stdio results with `agent-teams_`.
|
- 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')`.
|
- `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.
|
- 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
|
```ts
|
||||||
const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
|
const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
|
||||||
|
|
@ -1924,7 +1929,7 @@ const REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS = [
|
||||||
'runtime_deliver_message',
|
'runtime_deliver_message',
|
||||||
'runtime_task_event',
|
'runtime_task_event',
|
||||||
'runtime_heartbeat',
|
'runtime_heartbeat',
|
||||||
] as const
|
] as const;
|
||||||
|
|
||||||
const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [
|
const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [
|
||||||
'member_briefing',
|
'member_briefing',
|
||||||
|
|
@ -1956,20 +1961,20 @@ const REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS = [
|
||||||
'cross_team_send',
|
'cross_team_send',
|
||||||
'cross_team_list_targets',
|
'cross_team_list_targets',
|
||||||
'cross_team_get_outbox',
|
'cross_team_get_outbox',
|
||||||
] as const
|
] as const;
|
||||||
|
|
||||||
const REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES = [
|
const REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES = [
|
||||||
...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS,
|
...REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS,
|
||||||
...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS,
|
...REQUIRED_AGENT_TEAMS_TEAMMATE_OPERATIONAL_TOOLS,
|
||||||
] as const
|
] as const;
|
||||||
```
|
```
|
||||||
|
|
||||||
Update direct listTools mapping:
|
Update direct listTools mapping:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
return (result.tools ?? [])
|
return (result.tools ?? [])
|
||||||
.map(tool => tool.name)
|
.map((tool) => tool.name)
|
||||||
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0)
|
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0);
|
||||||
```
|
```
|
||||||
|
|
||||||
Compare plain names internally:
|
Compare plain names internally:
|
||||||
|
|
@ -1988,14 +1993,14 @@ But emit canonical ids for bridge readiness/evidence:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
function buildOpenCodeCanonicalMcpToolId(toolName: string): string {
|
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 {
|
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(
|
const missingTools = REQUIRED_AGENT_TEAMS_DIRECT_MCP_TOOL_NAMES.filter(
|
||||||
tool => !observedDirect.has(tool)
|
(tool) => !observedDirect.has(tool)
|
||||||
)
|
);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
ok: missingTools.length === 0,
|
ok: missingTools.length === 0,
|
||||||
|
|
@ -2004,11 +2009,12 @@ function matchAppMcpTools(observedDirectToolNames: string[], route: string): App
|
||||||
),
|
),
|
||||||
observedDirectToolNames: uniqueSortedStrings(observedDirectToolNames),
|
observedDirectToolNames: uniqueSortedStrings(observedDirectToolNames),
|
||||||
missingTools,
|
missingTools,
|
||||||
diagnostics: missingTools.length === 0
|
diagnostics:
|
||||||
|
missingTools.length === 0
|
||||||
? []
|
? []
|
||||||
: [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`],
|
: [`OpenCode app MCP tools missing from ${route}: ${missingTools.join(', ')}`],
|
||||||
route,
|
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 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`.
|
- 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:
|
Files:
|
||||||
|
|
||||||
```text
|
```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/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/mcp/OpenCodeMcpToolAvailability.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
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Current risk:
|
Current rule:
|
||||||
|
|
||||||
```ts
|
- OpenCode launch readiness should not require a project-scoped proof artifact.
|
||||||
requiredMcpTools: REQUIRED_AGENT_TEAMS_RUNTIME_TOOLS.map((tool) =>
|
- App tool proof belongs in the live readiness path through capability, runtime-store, MCP tool, and execution checks.
|
||||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
- If tool requirements change, update `OpenCodeMcpToolAvailability` and readiness tests directly.
|
||||||
)
|
|
||||||
```
|
|
||||||
|
|
||||||
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`.
|
|
||||||
|
|
||||||
Acceptance:
|
Acceptance:
|
||||||
|
|
||||||
- Production evidence must include canonical ids for runtime and teammate-operational tools.
|
- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are covered by readiness tests.
|
||||||
- `message_send`, `member_briefing`, `task_start`, and `cross_team_send` are explicitly covered by tests.
|
- Missing app MCP tools fails readiness directly with a clear diagnostic.
|
||||||
- Old runtime-only evidence fails production mode with a clear diagnostic listing missing app MCP tools.
|
- No project-specific artifact is required to create or launch a team.
|
||||||
- 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.
|
|
||||||
|
|
||||||
### Step 13 - Resolve secondary lane current-run evidence from lane manifest
|
### 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:
|
Change to async durable resolution:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId(
|
const currentRunId = await this.resolveDurableOpenCodeRuntimeRunId(input.teamName, input.laneId);
|
||||||
input.teamName,
|
|
||||||
input.laneId
|
|
||||||
);
|
|
||||||
|
|
||||||
await store.assertEvidenceAccepted({
|
await store.assertEvidenceAccepted({
|
||||||
teamName: input.teamName,
|
teamName: input.teamName,
|
||||||
|
|
@ -2384,7 +2317,7 @@ emit: (event) => {
|
||||||
teamName: event.teamName,
|
teamName: event.teamName,
|
||||||
detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined,
|
detail: typeof event.data?.detail === 'string' ? event.data.detail : undefined,
|
||||||
});
|
});
|
||||||
}
|
};
|
||||||
```
|
```
|
||||||
|
|
||||||
Reason:
|
Reason:
|
||||||
|
|
@ -2422,7 +2355,7 @@ Do not add a frontend fake "agent answered" path. Frontend may show "message sav
|
||||||
These are the places most likely to produce regressions if implemented casually.
|
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
|
1. Canonical OpenCode MCP id spelling - 🎯 8 🛡️ 8 🧠 3, about 20-50 LOC in tests
|
||||||
`buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')` keeps the dash in `agent-teams_message_send`. Direct MCP stdio proof uses plain `message_send`. Transcript parsing accepts aliases. Production E2E evidence should use the canonical dash form only. Add tests for all three contexts so nobody normalizes everything to underscore by accident.
|
`buildOpenCodeCanonicalMcpToolId('agent-teams', 'message_send')` keeps the dash in `agent-teams_message_send`. Direct MCP stdio proof uses plain `message_send`. Transcript parsing accepts aliases. Add tests for these contexts so nobody normalizes everything to underscore by accident.
|
||||||
|
|
||||||
2. Orchestrator explicit teammate tool list drift - 🎯 7 🛡️ 7 🧠 4, about 40-90 LOC in tests
|
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.
|
The v1 orchestrator list is duplicated by design to avoid adding a dependency. This is acceptable only if tests cover the current controller teammate-operational catalog snapshot, including attachment/link/create/cross-team tools. If this fails repeatedly, move to Option C from Step 10: generated shared protocol contract.
|
||||||
|
|
@ -2547,7 +2480,9 @@ it('persists taskRefs through message_send', async () => {
|
||||||
taskRefs: [{ teamName, taskId: 'task-1', displayId: 'abcd1234' }],
|
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' }]);
|
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',
|
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({
|
expect(rows.at(-1)).toMatchObject({
|
||||||
from: 'user',
|
from: 'user',
|
||||||
to: 'alice',
|
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', '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',
|
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' });
|
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',
|
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(
|
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' }]);
|
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
|
```ts
|
||||||
it('uses full app tool ids for OpenCode production E2E gate expectations', async () => {
|
it('uses full app tool ids for OpenCode readiness expectations', async () => {
|
||||||
const evidence = buildEvidence({
|
|
||||||
observedTools: REQUIRED_AGENT_TEAMS_APP_TOOL_IDS,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await bridge.runReadiness({
|
const result = await bridge.runReadiness({
|
||||||
launchMode: 'production',
|
|
||||||
selectedModel: 'minimax-m2.5-free',
|
selectedModel: 'minimax-m2.5-free',
|
||||||
// ...
|
// ...
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(result.supportLevel).toBe('production_supported');
|
expect(result.supportLevel).toBe('production_supported');
|
||||||
expect(productionE2eEvidence.read).toHaveBeenCalled();
|
expect(result.evidence.observedMcpTools).toEqual(
|
||||||
});
|
expect.arrayContaining(REQUIRED_AGENT_TEAMS_APP_TOOL_IDS)
|
||||||
```
|
|
||||||
|
|
||||||
```ts
|
|
||||||
it('rejects stale runtime-only OpenCode production evidence', async () => {
|
|
||||||
const runtimeOnlyToolIds = REQUIRED_AGENT_TEAMS_RUNTIME_PROOF_TOOLS.map((tool) =>
|
|
||||||
buildOpenCodeCanonicalMcpToolId('agent-teams', tool)
|
|
||||||
);
|
);
|
||||||
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
|
cd /Users/belief/dev/projects/claude/claude_team
|
||||||
pnpm --filter agent-teams-controller test -- test/controller.test.js
|
pnpm --filter agent-teams-controller test -- test/controller.test.js
|
||||||
pnpm --filter agent-teams-mcp test -- test/tools.test.ts
|
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
|
```bash
|
||||||
|
|
@ -3499,7 +3398,7 @@ Avoid heavy E2E until targeted tests pass.
|
||||||
19. Add OpenCode-targeted inbox runtime relay with dedupe/read marking.
|
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.
|
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.
|
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.
|
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.
|
24. Add runtime delivery `TeamChangeEvent.detail` adapter guard tests.
|
||||||
25. Add 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 `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.
|
- 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.
|
- 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.
|
- Readiness passes with runtime-only app tool coverage. This means `OpenCodeMcpToolAvailability` still uses only runtime tools instead of the full app tool id list.
|
||||||
- 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.
|
|
||||||
- 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.
|
- 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.
|
- 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.
|
- 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 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 cross-team instructions consistently use `agent-teams_cross_team_send`, not `message_send`.
|
||||||
- OpenCode readiness fails if required app MCP tools are absent.
|
- 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.
|
- 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.
|
- 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`.
|
- OpenCode can prove liveness through `runtime_bootstrap_checkin`.
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
import { jsonTextContent } from '../utils/format';
|
import { jsonTextContent } from '../utils/format';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -42,8 +43,9 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
replyToConversationId,
|
replyToConversationId,
|
||||||
taskRefs,
|
taskRefs,
|
||||||
chainDepth,
|
chainDepth,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
||||||
toTeam,
|
toTeam,
|
||||||
|
|
@ -56,7 +58,8 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(chainDepth !== undefined ? { chainDepth } : {}),
|
...(chainDepth !== undefined ? { chainDepth } : {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -66,14 +69,16 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
excludeTeam: z.string().optional(),
|
excludeTeam: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, excludeTeam }) =>
|
execute: async ({ teamName, claudeDir, excludeTeam }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
|
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
|
||||||
...(excludeTeam ? { excludeTeam } : {}),
|
...(excludeTeam ? { excludeTeam } : {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -82,9 +87,11 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) =>
|
execute: async ({ teamName, claudeDir }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
|
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
import { jsonTextContent } from '../utils/format';
|
import { jsonTextContent } from '../utils/format';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -16,8 +17,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) =>
|
execute: async ({ teamName, claudeDir }) => {
|
||||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState()));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -29,10 +32,12 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
column: z.enum(['review', 'approved']),
|
column: z.enum(['review', 'approved']),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, column }) =>
|
execute: async ({ teamName, claudeDir, taskId, column }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
|
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -43,8 +48,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
execute: async ({ teamName, claudeDir, taskId }) => {
|
||||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId)));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -53,8 +60,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) =>
|
execute: async ({ teamName, claudeDir }) => {
|
||||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers()));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -64,8 +73,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
reviewer: z.string().min(1),
|
reviewer: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
execute: async ({ teamName, claudeDir, reviewer }) => {
|
||||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer)));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -75,9 +86,11 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
reviewer: z.string().min(1),
|
reviewer: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
execute: async ({ teamName, claudeDir, reviewer }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
|
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -20,13 +21,16 @@ export function registerLeadTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) => ({
|
execute: async ({ teamName, claudeDir }) => {
|
||||||
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
|
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
import { jsonTextContent } from '../utils/format';
|
import { jsonTextContent } from '../utils/format';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
|
|
@ -53,8 +54,9 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
leadSessionId,
|
leadSessionId,
|
||||||
attachments,
|
attachments,
|
||||||
taskRefs,
|
taskRefs,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).messages.sendMessage({
|
getController(teamName, claudeDir).messages.sendMessage({
|
||||||
to,
|
to,
|
||||||
|
|
@ -67,6 +69,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(taskRefs?.length ? { taskRefs } : {}),
|
...(taskRefs?.length ? { taskRefs } : {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
import { jsonTextContent } from '../utils/format';
|
import { jsonTextContent } from '../utils/format';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -34,8 +35,9 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
port,
|
port,
|
||||||
url,
|
url,
|
||||||
claudeProcessId,
|
claudeProcessId,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).processes.registerProcess({
|
getController(teamName, claudeDir).processes.registerProcess({
|
||||||
pid,
|
pid,
|
||||||
|
|
@ -47,7 +49,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
|
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -57,10 +60,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) =>
|
execute: async ({ teamName, claudeDir }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
|
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -71,10 +76,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
pid: z.number().int().positive(),
|
pid: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, pid }) =>
|
execute: async ({ teamName, claudeDir, pid }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
|
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -85,9 +92,11 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
pid: z.number().int().positive(),
|
pid: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, pid }) =>
|
execute: async ({ teamName, claudeDir, pid }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
|
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
import { jsonTextContent, slimTask } from '../utils/format';
|
import { jsonTextContent, slimTask } from '../utils/format';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -20,8 +21,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
reviewer: z.string().optional(),
|
reviewer: z.string().optional(),
|
||||||
leadSessionId: z.string().optional(),
|
leadSessionId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
|
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
slimTask(
|
slimTask(
|
||||||
getController(teamName, claudeDir).review.requestReview(taskId, {
|
getController(teamName, claudeDir).review.requestReview(taskId, {
|
||||||
|
|
@ -31,7 +33,8 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -42,14 +45,16 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
from: z.string().optional(),
|
from: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, from }) =>
|
execute: async ({ teamName, claudeDir, taskId, from }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).review.startReview(taskId, {
|
getController(teamName, claudeDir).review.startReview(taskId, {
|
||||||
...(from ? { from } : {}),
|
...(from ? { from } : {}),
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -63,8 +68,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
notifyOwner: z.boolean().optional(),
|
notifyOwner: z.boolean().optional(),
|
||||||
leadSessionId: z.string().optional(),
|
leadSessionId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
|
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
slimTask(
|
slimTask(
|
||||||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||||
|
|
@ -75,7 +81,8 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -88,8 +95,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
comment: z.string().optional(),
|
comment: z.string().optional(),
|
||||||
leadSessionId: z.string().optional(),
|
leadSessionId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) =>
|
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
slimTask(
|
slimTask(
|
||||||
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
||||||
|
|
@ -99,6 +107,7 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
import { jsonTextContent } from '../utils/format';
|
import { jsonTextContent } from '../utils/format';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -57,8 +58,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
worktree,
|
worktree,
|
||||||
extraCliArgs,
|
extraCliArgs,
|
||||||
waitForReady,
|
waitForReady,
|
||||||
}) =>
|
}) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.launchTeam({
|
await getController(teamName, claudeDir).runtime.launchTeam({
|
||||||
cwd,
|
cwd,
|
||||||
...(prompt ? { prompt } : {}),
|
...(prompt ? { prompt } : {}),
|
||||||
|
|
@ -72,7 +74,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
...(waitForReady !== undefined ? { waitForReady } : {}),
|
...(waitForReady !== undefined ? { waitForReady } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -82,14 +85,16 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
waitForStop: z.boolean().optional(),
|
waitForStop: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) =>
|
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.stopTeam({
|
await getController(teamName, claudeDir).runtime.stopTeam({
|
||||||
...(controlUrl ? { controlUrl } : {}),
|
...(controlUrl ? { controlUrl } : {}),
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
...(waitForStop !== undefined ? { waitForStop } : {}),
|
...(waitForStop !== undefined ? { waitForStop } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -112,8 +117,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
observedAt,
|
observedAt,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
metadata,
|
metadata,
|
||||||
}) =>
|
}) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({
|
await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({
|
||||||
runId,
|
runId,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -124,7 +130,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(controlUrl ? { controlUrl } : {}),
|
...(controlUrl ? { controlUrl } : {}),
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -157,8 +164,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
createdAt,
|
createdAt,
|
||||||
summary,
|
summary,
|
||||||
taskRefs,
|
taskRefs,
|
||||||
}) =>
|
}) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({
|
await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
runId,
|
runId,
|
||||||
|
|
@ -172,7 +180,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(controlUrl ? { controlUrl } : {}),
|
...(controlUrl ? { controlUrl } : {}),
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -204,8 +213,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
createdAt,
|
createdAt,
|
||||||
summary,
|
summary,
|
||||||
metadata,
|
metadata,
|
||||||
}) =>
|
}) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.runtimeTaskEvent({
|
await getController(teamName, claudeDir).runtime.runtimeTaskEvent({
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
runId,
|
runId,
|
||||||
|
|
@ -219,7 +229,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(controlUrl ? { controlUrl } : {}),
|
...(controlUrl ? { controlUrl } : {}),
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -242,8 +253,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
observedAt,
|
observedAt,
|
||||||
status,
|
status,
|
||||||
metadata,
|
metadata,
|
||||||
}) =>
|
}) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.runtimeHeartbeat({
|
await getController(teamName, claudeDir).runtime.runtimeHeartbeat({
|
||||||
runId,
|
runId,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -254,6 +266,7 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(controlUrl ? { controlUrl } : {}),
|
...(controlUrl ? { controlUrl } : {}),
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import type { FastMCP } from 'fastmcp';
|
import type { FastMCP } from 'fastmcp';
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { agentBlocks, getController } from '../controller';
|
import { agentBlocks, getController } from '../controller';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
|
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
|
||||||
|
|
||||||
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag 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'>) {
|
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'task_create',
|
name: 'task_create',
|
||||||
|
|
@ -288,8 +251,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
execute: async ({ teamName, claudeDir, taskId }) => {
|
||||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
|
jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -301,12 +268,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
commentId: z.string().min(1),
|
commentId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, commentId }) =>
|
execute: async ({ teamName, claudeDir, taskId, commentId }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
jsonTextContent(
|
return await Promise.resolve(
|
||||||
getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId)
|
jsonTextContent(getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId))
|
||||||
)
|
);
|
||||||
),
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -333,8 +300,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
relatedTo,
|
relatedTo,
|
||||||
blockedBy,
|
blockedBy,
|
||||||
limit,
|
limit,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).tasks.listTaskInventory({
|
getController(teamName, claudeDir).tasks.listTaskInventory({
|
||||||
...(owner ? { owner } : {}),
|
...(owner ? { owner } : {}),
|
||||||
|
|
@ -346,7 +314,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
limit: normalizeTaskListLimit(limit),
|
limit: normalizeTaskListLimit(limit),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -358,10 +327,42 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
|
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
|
||||||
actor: z.string().optional(),
|
actor: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
|
execute: async ({ teamName, claudeDir, taskId, status, actor }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<string, unknown>))
|
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({
|
server.addTool({
|
||||||
|
|
@ -372,8 +373,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
actor: z.string().optional(),
|
actor: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||||
await Promise.resolve(jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<string, unknown>))),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
|
jsonTextContent(
|
||||||
|
slimTask(
|
||||||
|
getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -384,10 +396,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
actor: z.string().optional(),
|
actor: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<string, unknown>))
|
return await Promise.resolve(
|
||||||
),
|
jsonTextContent(
|
||||||
|
slimTask(
|
||||||
|
getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -398,10 +419,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
owner: z.string().nullable(),
|
owner: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, owner }) =>
|
execute: async ({ teamName, claudeDir, taskId, owner }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<string, unknown>))
|
return await Promise.resolve(
|
||||||
),
|
jsonTextContent(
|
||||||
|
slimTask(
|
||||||
|
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -413,8 +443,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
text: z.string().min(1),
|
text: z.string().min(1),
|
||||||
from: z.string().optional(),
|
from: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, text, from }) =>
|
execute: async ({ teamName, claudeDir, taskId, text, from }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
taskWriteResult(
|
taskWriteResult(
|
||||||
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
||||||
|
|
@ -423,7 +454,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -448,8 +480,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
filename,
|
filename,
|
||||||
mimeType,
|
mimeType,
|
||||||
noFallback,
|
noFallback,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
taskWriteResult(
|
taskWriteResult(
|
||||||
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
||||||
|
|
@ -461,7 +494,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -488,8 +522,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
filename,
|
filename,
|
||||||
mimeType,
|
mimeType,
|
||||||
noFallback,
|
noFallback,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
taskWriteResult(
|
taskWriteResult(
|
||||||
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
||||||
|
|
@ -501,7 +536,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -512,8 +548,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
value: z.enum(['lead', 'user', 'clear']),
|
value: z.enum(['lead', 'user', 'clear']),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, value }) =>
|
execute: async ({ teamName, claudeDir, taskId, value }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
slimTask(
|
slimTask(
|
||||||
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
||||||
|
|
@ -522,7 +559,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
) as Record<string, unknown>
|
) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -534,10 +572,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
targetId: z.string().min(1),
|
targetId: z.string().min(1),
|
||||||
relationship: relationshipTypeSchema,
|
relationship: relationshipTypeSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship) as Record<string, unknown>))
|
return await Promise.resolve(
|
||||||
),
|
jsonTextContent(
|
||||||
|
slimTask(
|
||||||
|
getController(teamName, claudeDir).tasks.linkTask(
|
||||||
|
taskId,
|
||||||
|
targetId,
|
||||||
|
relationship
|
||||||
|
) as Record<string, unknown>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -549,12 +597,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
targetId: z.string().min(1),
|
targetId: z.string().min(1),
|
||||||
relationship: relationshipTypeSchema,
|
relationship: relationshipTypeSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
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({
|
server.addTool({
|
||||||
|
|
@ -566,7 +622,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
memberName: z.string().min(1),
|
memberName: z.string().min(1),
|
||||||
runtimeProvider: z.enum(['native', 'opencode']).optional(),
|
runtimeProvider: z.enum(['native', 'opencode']).optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => ({
|
execute: async ({ teamName, claudeDir, memberName, runtimeProvider }) => {
|
||||||
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
|
|
@ -575,7 +633,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
}),
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -585,13 +644,16 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
memberName: z.string().min(1),
|
memberName: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, memberName }) => ({
|
execute: async ({ teamName, claudeDir, memberName }) => {
|
||||||
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return {
|
||||||
content: [
|
content: [
|
||||||
{
|
{
|
||||||
type: 'text' as const,
|
type: 'text' as const,
|
||||||
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
|
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 () => {
|
it('caps high-volume task_list inventory over stdio and keeps rows compact', async () => {
|
||||||
await writeTeamConfig(claudeDir, 'bulk-inventory-team');
|
await writeTeamConfig(claudeDir, 'bulk-inventory-team');
|
||||||
await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225);
|
await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225);
|
||||||
|
|
@ -1909,6 +1955,38 @@ describe('agent-teams-mcp stdio e2e', () => {
|
||||||
const startDeletedErrorText =
|
const startDeletedErrorText =
|
||||||
startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? '');
|
startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? '');
|
||||||
expect(startDeletedErrorText).toContain('use task_restore before starting work');
|
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 {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,9 @@ describe('agent-teams-mcp tools', () => {
|
||||||
res.end(JSON.stringify(result.body));
|
res.end(JSON.stringify(result.body));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.writeHead(500, { 'content-type': 'application/json' });
|
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 () => {
|
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 calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||||
const server = await startControlServer(async ({ method, url, body }) => {
|
const server = await startControlServer(async ({ method, url, body }) => {
|
||||||
calls.push({ method, url, body });
|
calls.push({ method, url, body });
|
||||||
|
|
@ -172,6 +178,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
try {
|
try {
|
||||||
const launched = parseJsonToolResult(
|
const launched = parseJsonToolResult(
|
||||||
await getTool('team_launch').execute({
|
await getTool('team_launch').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
cwd: '/tmp/project',
|
cwd: '/tmp/project',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
|
|
@ -183,6 +190,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
|
|
||||||
const stopped = parseJsonToolResult(
|
const stopped = parseJsonToolResult(
|
||||||
await getTool('team_stop').execute({
|
await getTool('team_stop').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
})
|
})
|
||||||
|
|
@ -217,6 +225,13 @@ describe('agent-teams-mcp tools', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => {
|
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 calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||||
const server = await startControlServer(async ({ method, url, body }) => {
|
const server = await startControlServer(async ({ method, url, body }) => {
|
||||||
calls.push({ method, url, body });
|
calls.push({ method, url, body });
|
||||||
|
|
@ -225,6 +240,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getTool('runtime_bootstrap_checkin').execute({
|
await getTool('runtime_bootstrap_checkin').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
runId: 'run-oc',
|
runId: 'run-oc',
|
||||||
|
|
@ -232,6 +248,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
runtimeSessionId: 'ses-1',
|
runtimeSessionId: 'ses-1',
|
||||||
});
|
});
|
||||||
await getTool('runtime_deliver_message').execute({
|
await getTool('runtime_deliver_message').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
idempotencyKey: 'idem-1',
|
idempotencyKey: 'idem-1',
|
||||||
|
|
@ -242,6 +259,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
text: 'hello',
|
text: 'hello',
|
||||||
});
|
});
|
||||||
await getTool('runtime_task_event').execute({
|
await getTool('runtime_task_event').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
idempotencyKey: 'idem-task-1',
|
idempotencyKey: 'idem-task-1',
|
||||||
|
|
@ -252,6 +270,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
event: 'started',
|
event: 'started',
|
||||||
});
|
});
|
||||||
await getTool('runtime_heartbeat').execute({
|
await getTool('runtime_heartbeat').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
runId: 'run-oc',
|
runId: 'run-oc',
|
||||||
|
|
@ -281,6 +300,9 @@ describe('agent-teams-mcp tools', () => {
|
||||||
|
|
||||||
it('discovers the control endpoint from the published state file', async () => {
|
it('discovers the control endpoint from the published state file', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
|
writeTeamConfig(claudeDir, 'alpha', {
|
||||||
|
members: [{ name: 'lead', role: 'team-lead' }],
|
||||||
|
});
|
||||||
const statePath = path.join(claudeDir, 'team-control-api.json');
|
const statePath = path.join(claudeDir, 'team-control-api.json');
|
||||||
|
|
||||||
const server = await startControlServer(async ({ method, url }) => {
|
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_start');
|
||||||
expect(ownerInbox[0].text).toContain('task_add_comment');
|
expect(ownerInbox[0].text).toContain('task_add_comment');
|
||||||
expect(ownerInbox[0].text).toContain('Read the plan before starting.');
|
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(
|
expect(ownerInbox[0].text).toContain(
|
||||||
'If you are busy, blocked, or still need more context, immediately add a short task comment'
|
'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].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');
|
expect(ownerInbox[3].text).toContain('task_add_comment');
|
||||||
|
|
||||||
const briefing = (await getTool('task_briefing').execute({
|
const briefing = (await getTool('task_briefing').execute({
|
||||||
|
|
@ -712,14 +738,22 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(memberBriefingText).toContain(
|
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.'
|
'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('Task briefing for alice:');
|
||||||
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
|
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
|
||||||
|
|
||||||
fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true });
|
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', 'carol.json'), '[]');
|
||||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]');
|
fs.writeFileSync(
|
||||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]');
|
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({
|
const inboxResolvedBriefing = (await getTool('member_briefing').execute({
|
||||||
claudeDir,
|
claudeDir,
|
||||||
|
|
@ -727,7 +761,9 @@ describe('agent-teams-mcp tools', () => {
|
||||||
memberName: 'carol',
|
memberName: 'carol',
|
||||||
})) as { content: Array<{ text: string }> };
|
})) as { content: Array<{ text: string }> };
|
||||||
const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? '';
|
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.');
|
expect(inboxResolvedBriefingText).toContain('Role: team member.');
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -914,9 +950,9 @@ describe('agent-teams-mcp tools', () => {
|
||||||
teamName,
|
teamName,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe(
|
expect(
|
||||||
'needsFix'
|
listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState
|
||||||
);
|
).toBe('needsFix');
|
||||||
|
|
||||||
const kanbanCleared = parseJsonToolResult(
|
const kanbanCleared = parseJsonToolResult(
|
||||||
await getTool('kanban_clear').execute({
|
await getTool('kanban_clear').execute({
|
||||||
|
|
@ -1061,6 +1097,26 @@ describe('agent-teams-mcp tools', () => {
|
||||||
);
|
);
|
||||||
expect(kanbanState.tasks[reviewTask.id]).toBeUndefined();
|
expect(kanbanState.tasks[reviewTask.id]).toBeUndefined();
|
||||||
expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id);
|
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 () => {
|
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', () => {
|
it('exposes zod schemas that reject obviously invalid payloads', () => {
|
||||||
expect(
|
expect(
|
||||||
getTool('task_create').parameters?.safeParse({
|
getTool('task_create').parameters?.safeParse({
|
||||||
|
|
@ -1370,9 +1461,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(completed.comments).toBeUndefined();
|
expect(completed.comments).toBeUndefined();
|
||||||
|
|
||||||
// task_list: explicit inventory shape only
|
// task_list: explicit inventory shape only
|
||||||
const listed = parseJsonToolResult(
|
const listed = parseJsonToolResult(await getTool('task_list').execute({ claudeDir, teamName }));
|
||||||
await getTool('task_list').execute({ claudeDir, teamName })
|
|
||||||
);
|
|
||||||
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
|
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
|
||||||
expect(listedTask).toBeDefined();
|
expect(listedTask).toBeDefined();
|
||||||
expect(listedTask).toEqual({
|
expect(listedTask).toEqual({
|
||||||
|
|
@ -1412,9 +1501,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json');
|
const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json');
|
||||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||||
fs.mkdirSync(teamDir, { recursive: true });
|
fs.mkdirSync(teamDir, { recursive: true });
|
||||||
const existing = fs.existsSync(sentPath)
|
const existing = fs.existsSync(sentPath) ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) : [];
|
||||||
? JSON.parse(fs.readFileSync(sentPath, 'utf8'))
|
|
||||||
: [];
|
|
||||||
existing.push(message);
|
existing.push(message);
|
||||||
fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2));
|
fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2));
|
||||||
}
|
}
|
||||||
|
|
@ -1760,9 +1847,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
text: 'Roundtrip test message',
|
text: 'Roundtrip test message',
|
||||||
timestamp: '2026-03-15T16:00:00.000Z',
|
timestamp: '2026-03-15T16:00:00.000Z',
|
||||||
source: 'user_sent',
|
source: 'user_sent',
|
||||||
attachments: [
|
attachments: [{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }],
|
||||||
{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const created = parseJsonToolResult(
|
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.'
|
'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": "node ./scripts/dev-with-runtime.mjs",
|
||||||
"dev:web": "node ./scripts/dev-web.mjs",
|
"dev:web": "node ./scripts/dev-web.mjs",
|
||||||
"dev:kill": "node bin/kill-dev.js",
|
"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-mixed-recovery": "node ./scripts/prove-opencode-mixed-recovery.mjs",
|
||||||
"opencode:prove-team-provisioning": "node ./scripts/prove-opencode-team-provisioning.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",
|
"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) {
|
for (let index = 0; index < 3; index += 1) {
|
||||||
const angle = time * 1.2 + (Math.PI * 2 * index) / 3;
|
const angle = time * 1.2 + (Math.PI * 2 * index) / 3;
|
||||||
ctx.beginPath();
|
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.fillStyle = hexWithAlpha('#e4e4e7', 0.72);
|
||||||
ctx.fill();
|
ctx.fill();
|
||||||
}
|
}
|
||||||
|
|
@ -736,6 +742,13 @@ function getLaunchStatusColor(visualState: GraphNode['launchVisualState']): stri
|
||||||
return hexWithAlpha('#f59e0b', 0.92);
|
return hexWithAlpha('#f59e0b', 0.92);
|
||||||
case 'runtime_pending':
|
case 'runtime_pending':
|
||||||
return hexWithAlpha('#67e8f9', 0.9);
|
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':
|
case 'settling':
|
||||||
return hexWithAlpha('#22c55e', 0.9);
|
return hexWithAlpha('#22c55e', 0.9);
|
||||||
case 'error':
|
case 'error':
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,13 @@ export type GraphLaunchVisualState =
|
||||||
| 'spawning'
|
| 'spawning'
|
||||||
| 'permission_pending'
|
| 'permission_pending'
|
||||||
| 'runtime_pending'
|
| 'runtime_pending'
|
||||||
|
| 'shell_only'
|
||||||
|
| 'runtime_candidate'
|
||||||
|
| 'registered_only'
|
||||||
|
| 'stale_runtime'
|
||||||
| 'settling'
|
| 'settling'
|
||||||
| 'error';
|
| 'error'
|
||||||
|
| 'skipped';
|
||||||
|
|
||||||
// ─── Edge & Particle Types ───────────────────────────────────────────────────
|
// ─── Edge & Particle Types ───────────────────────────────────────────────────
|
||||||
|
|
||||||
|
|
@ -82,7 +87,7 @@ export interface GraphNode {
|
||||||
/** Avatar image URL (e.g., robohash) */
|
/** Avatar image URL (e.g., robohash) */
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
/** Spawn lifecycle status */
|
/** 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 */
|
/** Shared launch-stage visual derived by the host app */
|
||||||
launchVisualState?: GraphLaunchVisualState;
|
launchVisualState?: GraphLaunchVisualState;
|
||||||
/** Shared launch-stage text shown beside the node during launch only */
|
/** 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())
|
? path.resolve(process.env.CLAUDE_DEV_RUNTIME_CACHE_ROOT.trim())
|
||||||
: defaultRuntimeCacheRoot;
|
: defaultRuntimeCacheRoot;
|
||||||
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
|
const shouldPrintRuntimePath = process.argv.includes('--print-runtime-path');
|
||||||
|
const runtimeDisplayName = 'teams orchestrator';
|
||||||
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
|
const WINDOWS_SHELL_COMMANDS = new Set(['pnpm', 'npm', 'npx', 'yarn', 'yarnpkg', 'corepack']);
|
||||||
|
|
||||||
function shouldUseWindowsShell(cmd) {
|
function shouldUseWindowsShell(cmd) {
|
||||||
|
|
@ -108,7 +109,8 @@ function getPlatformAssetKey() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function getReleaseAssetUrl(runtimeLock, asset) {
|
function getReleaseAssetUrl(runtimeLock, asset) {
|
||||||
const releaseTag = typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
|
const releaseTag =
|
||||||
|
typeof runtimeLock.releaseTag === 'string' && runtimeLock.releaseTag.trim().length > 0
|
||||||
? runtimeLock.releaseTag.trim()
|
? runtimeLock.releaseTag.trim()
|
||||||
: runtimeLock.sourceRef;
|
: runtimeLock.sourceRef;
|
||||||
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`;
|
return `https://github.com/${runtimeLock.releaseRepository}/releases/download/${releaseTag}/${encodeURIComponent(asset.file)}`;
|
||||||
|
|
@ -152,9 +154,7 @@ function truncateMiddle(value, maxLength) {
|
||||||
|
|
||||||
function buildProgressBar(progressRatio, width) {
|
function buildProgressBar(progressRatio, width) {
|
||||||
const safeWidth = Math.max(10, width);
|
const safeWidth = Math.max(10, width);
|
||||||
const clampedRatio = Number.isFinite(progressRatio)
|
const clampedRatio = Number.isFinite(progressRatio) ? Math.min(1, Math.max(0, progressRatio)) : 0;
|
||||||
? Math.min(1, Math.max(0, progressRatio))
|
|
||||||
: 0;
|
|
||||||
const filledWidth = Math.round(safeWidth * clampedRatio);
|
const filledWidth = Math.round(safeWidth * clampedRatio);
|
||||||
return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`;
|
return `${'='.repeat(filledWidth)}${'-'.repeat(safeWidth - filledWidth)}`;
|
||||||
}
|
}
|
||||||
|
|
@ -164,7 +164,8 @@ function supportsProgressRedraw() {
|
||||||
}
|
}
|
||||||
|
|
||||||
function formatProgressLine(label, writtenBytes, totalBytes, hasTotal) {
|
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 ratio = hasTotal ? writtenBytes / totalBytes : 0;
|
||||||
const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : '';
|
const percentText = hasTotal ? ` ${Math.floor(ratio * 100)}%` : '';
|
||||||
const bytesText = hasTotal
|
const bytesText = hasTotal
|
||||||
|
|
@ -196,6 +197,16 @@ function readBinaryVersion(binaryPath) {
|
||||||
return runAndCapture(binaryPath, ['--version']);
|
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) {
|
function isExecutable(filePath) {
|
||||||
if (!fs.existsSync(filePath)) {
|
if (!fs.existsSync(filePath)) {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -305,7 +316,10 @@ async function downloadWithProgress(url, destinationPath) {
|
||||||
readline.clearLine(process.stdout, 0);
|
readline.clearLine(process.stdout, 0);
|
||||||
readline.cursorTo(process.stdout, 0);
|
readline.cursorTo(process.stdout, 0);
|
||||||
process.stdout.write(`${formatProgressLine(label, writtenBytes, totalBytes, hasTotal)}\n`);
|
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`);
|
process.stdout.write(`${formatProgressSummary(writtenBytes, totalBytes, hasTotal)}\n`);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -511,7 +525,9 @@ async function main() {
|
||||||
if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) {
|
if ('cacheDir' in resolvedRuntime && resolvedRuntime.cacheDir) {
|
||||||
process.stdout.write(`Runtime cache: ${resolvedRuntime.cacheDir}\n`);
|
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 = {
|
const uiEnv = {
|
||||||
...process.env,
|
...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';
|
} from '../ports/RecentProjectsSourcePort';
|
||||||
|
|
||||||
const DEFAULT_CACHE_TTL_MS = 10_000;
|
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 {
|
interface SourceLoadResult {
|
||||||
candidates: RecentProjectCandidate[];
|
candidates: RecentProjectCandidate[];
|
||||||
|
|
@ -99,9 +99,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewModel = this.deps.output.present(response);
|
const viewModel = this.deps.output.present(response);
|
||||||
const cacheTtlMs = hasDegradedSources
|
const cacheTtlMs = hasDegradedSources ? this.#degradedCacheTtlMs : this.#cacheTtlMs;
|
||||||
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
|
|
||||||
: this.#cacheTtlMs;
|
|
||||||
|
|
||||||
await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
|
await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
|
||||||
this.deps.logger.info('recent-projects loaded', {
|
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 { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
|
||||||
import type { ServiceContext } from '@main/services';
|
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_INITIALIZE_TIMEOUT_MS = 6_000;
|
||||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000;
|
||||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000;
|
||||||
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
||||||
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
|
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_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
|
||||||
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
|
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
|
||||||
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_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 {
|
function isInteractiveSource(source: unknown): boolean {
|
||||||
return source === 'vscode' || source === 'cli';
|
return source === 'vscode' || source === 'cli';
|
||||||
|
|
@ -42,9 +52,24 @@ function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean {
|
||||||
return Boolean(result.live.error || result.archived.error);
|
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 {
|
export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
|
||||||
readonly sourceId = 'codex';
|
readonly sourceId = 'codex';
|
||||||
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
|
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
|
||||||
|
#staleCandidatesSnapshot: StaleCodexCandidatesSnapshot | null = null;
|
||||||
|
#fullFailureCooldownUntil = 0;
|
||||||
|
#fullFailureCooldownReason: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly deps: {
|
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 threadSegments = await this.#listRecentThreadsSafe(binaryPath);
|
||||||
const degraded = isDegradedThreadResult(threadSegments);
|
const degraded = isDegradedThreadResult(threadSegments);
|
||||||
|
const fullFailureReason = getFullFailureReason(threadSegments);
|
||||||
|
this.#updateFullFailureCooldown(fullFailureReason);
|
||||||
this.#logSegmentFailure(threadSegments, 'live');
|
this.#logSegmentFailure(threadSegments, 'live');
|
||||||
this.#logSegmentFailure(threadSegments, 'archived');
|
this.#logSegmentFailure(threadSegments, 'archived');
|
||||||
const liveThreads = threadSegments.live.threads;
|
const liveThreads = threadSegments.live.threads;
|
||||||
|
|
@ -92,6 +128,25 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
||||||
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
|
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
|
||||||
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
|
).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', {
|
this.deps.logger.info('codex recent-projects source loaded', {
|
||||||
count: candidates.length,
|
count: candidates.length,
|
||||||
degraded,
|
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> {
|
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
||||||
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
|
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
|
||||||
limit: CODEX_THREAD_LIMIT,
|
limit: CODEX_THREAD_LIMIT,
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,7 @@ export class CodexAppServerClient {
|
||||||
}
|
}
|
||||||
): Promise<CodexThreadSegmentResult> {
|
): Promise<CodexThreadSegmentResult> {
|
||||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
const initializeTimeoutMs = Math.max(
|
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
||||||
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
|
||||||
requestTimeoutMs
|
|
||||||
);
|
|
||||||
const totalTimeoutMs = Math.max(
|
const totalTimeoutMs = Math.max(
|
||||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||||
initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_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 liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
||||||
const initializeTimeoutMs = Math.max(
|
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
||||||
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
|
||||||
sessionRequestTimeoutMs
|
|
||||||
);
|
|
||||||
const totalTimeoutMs = Math.max(
|
const totalTimeoutMs = Math.max(
|
||||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||||
initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
initializeTimeoutMs +
|
||||||
|
liveRequestTimeoutMs +
|
||||||
|
archivedRequestTimeoutMs +
|
||||||
|
MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.#withThreadListSession(
|
return this.#withThreadListSession(
|
||||||
|
|
@ -140,50 +137,55 @@ export class CodexAppServerClient {
|
||||||
label: 'codex app-server thread/list',
|
label: 'codex app-server thread/list',
|
||||||
},
|
},
|
||||||
async (session) => {
|
async (session) => {
|
||||||
const [live, archived] = await Promise.allSettled([
|
const live = await this.#requestThreadListSegment(session, {
|
||||||
session.request<ThreadListResponse>(
|
|
||||||
'thread/list',
|
|
||||||
{
|
|
||||||
archived: false,
|
archived: false,
|
||||||
limit: options.limit,
|
limit: options.limit,
|
||||||
sortKey: 'updated_at',
|
timeoutMs: liveRequestTimeoutMs,
|
||||||
},
|
});
|
||||||
liveRequestTimeoutMs
|
const archived = await this.#requestThreadListSegment(session, {
|
||||||
),
|
|
||||||
session.request<ThreadListResponse>(
|
|
||||||
'thread/list',
|
|
||||||
{
|
|
||||||
archived: true,
|
archived: true,
|
||||||
limit: options.limit,
|
limit: options.limit,
|
||||||
sortKey: 'updated_at',
|
timeoutMs: archivedRequestTimeoutMs,
|
||||||
},
|
});
|
||||||
archivedRequestTimeoutMs
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
live:
|
live,
|
||||||
live.status === 'fulfilled'
|
archived,
|
||||||
? { 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),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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>(
|
async #withThreadListSession<T>(
|
||||||
options: ThreadListSessionOptions,
|
options: ThreadListSessionOptions,
|
||||||
handler: (session: JsonRpcSession) => Promise<T>
|
handler: (session: JsonRpcSession) => Promise<T>
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ import type { TeamSummary } from '@shared/types';
|
||||||
|
|
||||||
const INITIAL_RECENT_PROJECTS = 11;
|
const INITIAL_RECENT_PROJECTS = 11;
|
||||||
const LOAD_MORE_STEP = 8;
|
const LOAD_MORE_STEP = 8;
|
||||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500;
|
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 30_000;
|
||||||
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000;
|
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 120_000;
|
||||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3;
|
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3;
|
||||||
|
|
||||||
function matchesSearch(project: DashboardRecentProject, query: string): boolean {
|
function matchesSearch(project: DashboardRecentProject, query: string): boolean {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type {
|
||||||
} from '@features/recent-projects/contracts';
|
} from '@features/recent-projects/contracts';
|
||||||
|
|
||||||
const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
|
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 cachedPayload: DashboardRecentProjectsPayloadLike = null;
|
||||||
let cachedAt = 0;
|
let cachedAt = 0;
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 0,
|
runtimeAlivePendingCount: 0,
|
||||||
|
shellOnlyPendingCount: 0,
|
||||||
|
runtimeProcessPendingCount: 0,
|
||||||
|
runtimeCandidatePendingCount: 0,
|
||||||
|
noRuntimePendingCount: 0,
|
||||||
|
permissionPendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
||||||
});
|
});
|
||||||
|
|
@ -130,6 +135,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
runtimePid: 333,
|
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'],
|
diagnostics: ['spawn accepted', 'late heartbeat received'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -145,12 +155,22 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
runtimeAlive: true,
|
runtimeAlive: true,
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
runtimePid: 333,
|
runtimePid: 333,
|
||||||
|
runtimeSessionId: 'session-bob',
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
pidSource: 'runtime_bootstrap',
|
||||||
|
runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted',
|
||||||
|
runtimeDiagnosticSeverity: 'info',
|
||||||
});
|
});
|
||||||
expect(snapshot.summary).toEqual({
|
expect(snapshot.summary).toEqual({
|
||||||
confirmedCount: 2,
|
confirmedCount: 2,
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 0,
|
runtimeAlivePendingCount: 0,
|
||||||
|
shellOnlyPendingCount: 0,
|
||||||
|
runtimeProcessPendingCount: 0,
|
||||||
|
runtimeCandidatePendingCount: 0,
|
||||||
|
noRuntimePendingCount: 0,
|
||||||
|
permissionPendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(snapshot.teamLaunchState).toBe('clean_success');
|
expect(snapshot.teamLaunchState).toBe('clean_success');
|
||||||
});
|
});
|
||||||
|
|
@ -229,6 +249,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
failedCount: 1,
|
failedCount: 1,
|
||||||
runtimeAlivePendingCount: 0,
|
runtimeAlivePendingCount: 0,
|
||||||
|
shellOnlyPendingCount: 0,
|
||||||
|
runtimeProcessPendingCount: 0,
|
||||||
|
runtimeCandidatePendingCount: 0,
|
||||||
|
noRuntimePendingCount: 0,
|
||||||
|
permissionPendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(snapshot.teamLaunchState).toBe('partial_failure');
|
expect(snapshot.teamLaunchState).toBe('partial_failure');
|
||||||
});
|
});
|
||||||
|
|
@ -279,9 +304,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
evidence: {
|
evidence: {
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
|
livenessKind: 'permission_blocked',
|
||||||
|
runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
|
||||||
|
runtimeDiagnosticSeverity: 'warning',
|
||||||
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -292,9 +320,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
laneKind: 'secondary',
|
laneKind: 'secondary',
|
||||||
laneOwnerProviderId: 'opencode',
|
laneOwnerProviderId: 'opencode',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
|
livenessKind: 'permission_blocked',
|
||||||
|
runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
|
||||||
|
runtimeDiagnosticSeverity: 'warning',
|
||||||
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
|
|
@ -303,7 +334,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
confirmedCount: 1,
|
confirmedCount: 1,
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
|
shellOnlyPendingCount: 0,
|
||||||
|
runtimeProcessPendingCount: 0,
|
||||||
|
runtimeCandidatePendingCount: 0,
|
||||||
|
noRuntimePendingCount: 0,
|
||||||
|
permissionPendingCount: 1,
|
||||||
});
|
});
|
||||||
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import type {
|
||||||
PersistedTeamLaunchPhase,
|
PersistedTeamLaunchPhase,
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
ProviderModelLaunchIdentity,
|
ProviderModelLaunchIdentity,
|
||||||
|
TeamAgentRuntimeDiagnosticSeverity,
|
||||||
|
TeamAgentRuntimeLivenessKind,
|
||||||
|
TeamAgentRuntimePidSource,
|
||||||
TeamFastMode,
|
TeamFastMode,
|
||||||
TeamProviderBackendId,
|
TeamProviderBackendId,
|
||||||
TeamProviderId,
|
TeamProviderId,
|
||||||
|
|
@ -38,6 +41,12 @@ export interface MixedSecondaryLaneMemberStateInput {
|
||||||
hardFailureReason?: string;
|
hardFailureReason?: string;
|
||||||
pendingPermissionRequestIds?: string[];
|
pendingPermissionRequestIds?: string[];
|
||||||
runtimePid?: number;
|
runtimePid?: number;
|
||||||
|
runtimeSessionId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
|
pidSource?: TeamAgentRuntimePidSource;
|
||||||
|
runtimeDiagnostic?: string;
|
||||||
|
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
} | null;
|
} | null;
|
||||||
pendingReason?: string;
|
pendingReason?: string;
|
||||||
|
|
@ -65,6 +74,19 @@ function deriveMemberLaunchState(params: {
|
||||||
return 'starting';
|
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(
|
function buildDiagnostics(
|
||||||
member: Pick<
|
member: Pick<
|
||||||
PersistedTeamLaunchMemberState,
|
PersistedTeamLaunchMemberState,
|
||||||
|
|
@ -93,14 +115,17 @@ function buildDiagnostics(
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSourcesFromStatus(
|
function createSourcesFromStatus(
|
||||||
status: Pick<MemberSpawnStatusEntry, 'livenessSource' | 'runtimeAlive'>
|
status: Pick<
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
|
'livenessSource' | 'runtimeAlive' | 'bootstrapConfirmed' | 'livenessKind'
|
||||||
|
>
|
||||||
): PersistedTeamLaunchMemberSources | undefined {
|
): PersistedTeamLaunchMemberSources | undefined {
|
||||||
const sources: PersistedTeamLaunchMemberSources = {};
|
const sources: PersistedTeamLaunchMemberSources = {};
|
||||||
if (status.livenessSource === 'heartbeat') {
|
if (status.livenessSource === 'heartbeat') {
|
||||||
sources.nativeHeartbeat = true;
|
sources.nativeHeartbeat = true;
|
||||||
sources.inboxHeartbeat = true;
|
sources.inboxHeartbeat = true;
|
||||||
}
|
}
|
||||||
if (status.livenessSource === 'process' || status.runtimeAlive) {
|
if (status.livenessSource === 'process' && preservesStrongRuntimeAlive(status)) {
|
||||||
sources.processAlive = true;
|
sources.processAlive = true;
|
||||||
}
|
}
|
||||||
return Object.values(sources).some(Boolean) ? sources : undefined;
|
return Object.values(sources).some(Boolean) ? sources : undefined;
|
||||||
|
|
@ -119,6 +144,7 @@ function createPrimaryLaneMemberState(params: {
|
||||||
const providerId =
|
const providerId =
|
||||||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||||
const runtime = params.status;
|
const runtime = params.status;
|
||||||
|
const strongRuntimeAlive = preservesStrongRuntimeAlive(runtime ?? {});
|
||||||
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
|
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
|
||||||
const base: PersistedTeamLaunchMemberState = {
|
const base: PersistedTeamLaunchMemberState = {
|
||||||
name: params.member.name.trim(),
|
name: params.member.name.trim(),
|
||||||
|
|
@ -151,21 +177,25 @@ function createPrimaryLaneMemberState(params: {
|
||||||
deriveMemberLaunchState({
|
deriveMemberLaunchState({
|
||||||
hardFailure: runtime?.hardFailure,
|
hardFailure: runtime?.hardFailure,
|
||||||
bootstrapConfirmed: runtime?.bootstrapConfirmed,
|
bootstrapConfirmed: runtime?.bootstrapConfirmed,
|
||||||
runtimeAlive: runtime?.runtimeAlive,
|
runtimeAlive: strongRuntimeAlive,
|
||||||
agentToolAccepted: runtime?.agentToolAccepted,
|
agentToolAccepted: runtime?.agentToolAccepted,
|
||||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
|
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
|
||||||
}),
|
}),
|
||||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||||
runtimeAlive: runtime?.runtimeAlive === true,
|
runtimeAlive: strongRuntimeAlive,
|
||||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||||
: undefined,
|
: undefined,
|
||||||
|
livenessKind: runtime?.livenessKind,
|
||||||
|
runtimeDiagnostic: runtime?.runtimeDiagnostic,
|
||||||
|
runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
|
||||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? params.updatedAt : undefined,
|
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
|
||||||
|
lastRuntimeAliveAt: preservesStrongRuntimeAlive(runtime ?? {}) ? params.updatedAt : undefined,
|
||||||
lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt,
|
lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt,
|
||||||
sources,
|
sources,
|
||||||
diagnostics: undefined,
|
diagnostics: undefined,
|
||||||
|
|
@ -180,13 +210,14 @@ function createSecondaryLaneMemberState(
|
||||||
const providerId =
|
const providerId =
|
||||||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||||
const evidence = params.evidence;
|
const evidence = params.evidence;
|
||||||
|
const strongRuntimeAlive = preservesStrongRuntimeAlive(evidence ?? {});
|
||||||
const hardFailureReason = evidence?.hardFailureReason;
|
const hardFailureReason = evidence?.hardFailureReason;
|
||||||
const launchState =
|
const launchState =
|
||||||
evidence?.launchState ??
|
evidence?.launchState ??
|
||||||
deriveMemberLaunchState({
|
deriveMemberLaunchState({
|
||||||
hardFailure: evidence?.hardFailure,
|
hardFailure: evidence?.hardFailure,
|
||||||
bootstrapConfirmed: evidence?.bootstrapConfirmed,
|
bootstrapConfirmed: evidence?.bootstrapConfirmed,
|
||||||
runtimeAlive: evidence?.runtimeAlive,
|
runtimeAlive: strongRuntimeAlive,
|
||||||
agentToolAccepted: evidence?.agentToolAccepted,
|
agentToolAccepted: evidence?.agentToolAccepted,
|
||||||
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
|
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
|
||||||
});
|
});
|
||||||
|
|
@ -214,7 +245,7 @@ function createSecondaryLaneMemberState(
|
||||||
laneOwnerProviderId: providerId,
|
laneOwnerProviderId: providerId,
|
||||||
launchState,
|
launchState,
|
||||||
agentToolAccepted: evidence?.agentToolAccepted === true,
|
agentToolAccepted: evidence?.agentToolAccepted === true,
|
||||||
runtimeAlive: evidence?.runtimeAlive === true,
|
runtimeAlive: strongRuntimeAlive,
|
||||||
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
|
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
|
||||||
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
|
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
|
||||||
hardFailureReason,
|
hardFailureReason,
|
||||||
|
|
@ -227,15 +258,21 @@ function createSecondaryLaneMemberState(
|
||||||
evidence.runtimePid > 0
|
evidence.runtimePid > 0
|
||||||
? Math.trunc(evidence.runtimePid)
|
? Math.trunc(evidence.runtimePid)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId,
|
||||||
|
livenessKind: evidence?.livenessKind,
|
||||||
|
pidSource: evidence?.pidSource,
|
||||||
|
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
||||||
|
runtimeDiagnosticSeverity: evidence?.runtimeDiagnosticSeverity,
|
||||||
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
||||||
lastHeartbeatAt: evidence?.bootstrapConfirmed ? 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,
|
lastEvaluatedAt: params.updatedAt,
|
||||||
sources: evidence?.runtimeAlive
|
sources: strongRuntimeAlive
|
||||||
? {
|
? {
|
||||||
processAlive: true,
|
processAlive: true,
|
||||||
nativeHeartbeat: evidence.bootstrapConfirmed === true || undefined,
|
nativeHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
|
||||||
inboxHeartbeat: evidence.bootstrapConfirmed === true || undefined,
|
inboxHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
diagnostics: evidence?.diagnostics?.length
|
diagnostics: evidence?.diagnostics?.length
|
||||||
|
|
@ -256,6 +293,11 @@ function summarizeMembers(
|
||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
let runtimeAlivePendingCount = 0;
|
let runtimeAlivePendingCount = 0;
|
||||||
|
let shellOnlyPendingCount = 0;
|
||||||
|
let runtimeProcessPendingCount = 0;
|
||||||
|
let runtimeCandidatePendingCount = 0;
|
||||||
|
let noRuntimePendingCount = 0;
|
||||||
|
let permissionPendingCount = 0;
|
||||||
|
|
||||||
for (const memberName of expectedMembers) {
|
for (const memberName of expectedMembers) {
|
||||||
const entry = members[memberName];
|
const entry = members[memberName];
|
||||||
|
|
@ -275,6 +317,22 @@ function summarizeMembers(
|
||||||
if (entry.runtimeAlive) {
|
if (entry.runtimeAlive) {
|
||||||
runtimeAlivePendingCount += 1;
|
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 {
|
return {
|
||||||
|
|
@ -282,6 +340,11 @@ function summarizeMembers(
|
||||||
pendingCount,
|
pendingCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
runtimeAlivePendingCount,
|
runtimeAlivePendingCount,
|
||||||
|
shellOnlyPendingCount,
|
||||||
|
runtimeProcessPendingCount,
|
||||||
|
runtimeCandidatePendingCount,
|
||||||
|
noRuntimePendingCount,
|
||||||
|
permissionPendingCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,9 @@
|
||||||
import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter';
|
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 runtimeStatusSource = new TmuxStatusSourceAdapter();
|
||||||
const runtimeCommandExecutor = new TmuxPlatformCommandExecutor();
|
const runtimeCommandExecutor = new TmuxPlatformCommandExecutor();
|
||||||
|
|
@ -24,6 +28,18 @@ export async function listTmuxPanePidsForCurrentPlatform(
|
||||||
return runtimeCommandExecutor.listPanePids(paneIds);
|
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 {
|
export function killTmuxPaneForCurrentPlatformSync(paneId: string): void {
|
||||||
runtimeCommandExecutor.killPaneSync(paneId);
|
runtimeCommandExecutor.killPaneSync(paneId);
|
||||||
invalidateTmuxRuntimeStatusCache();
|
invalidateTmuxRuntimeStatusCache();
|
||||||
|
|
|
||||||
|
|
@ -9,5 +9,12 @@ export {
|
||||||
isTmuxRuntimeReadyForCurrentPlatform,
|
isTmuxRuntimeReadyForCurrentPlatform,
|
||||||
killTmuxPaneForCurrentPlatform,
|
killTmuxPaneForCurrentPlatform,
|
||||||
killTmuxPaneForCurrentPlatformSync,
|
killTmuxPaneForCurrentPlatformSync,
|
||||||
|
listRuntimeProcessesForCurrentTmuxPlatform,
|
||||||
listTmuxPanePidsForCurrentPlatform,
|
listTmuxPanePidsForCurrentPlatform,
|
||||||
|
listTmuxPaneRuntimeInfoForCurrentPlatform,
|
||||||
} from './composition/runtimeSupport';
|
} 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;
|
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 {
|
export class TmuxPlatformCommandExecutor {
|
||||||
readonly #wslService: TmuxWslService;
|
readonly #wslService: TmuxWslService;
|
||||||
readonly #packageManagerResolver: TmuxPackageManagerResolver;
|
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))];
|
const normalizedPaneIds = [...new Set(paneIds.map((paneId) => paneId.trim()).filter(Boolean))];
|
||||||
if (normalizedPaneIds.length === 0) {
|
if (normalizedPaneIds.length === 0) {
|
||||||
return new Map();
|
return new Map();
|
||||||
}
|
}
|
||||||
|
|
||||||
const result = await this.execTmux(
|
const format = [
|
||||||
['list-panes', '-a', '-F', '#{pane_id}\t#{pane_pid}'],
|
'#{pane_id}',
|
||||||
3_000
|
'#{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) {
|
if (result.exitCode !== 0) {
|
||||||
throw new Error(result.stderr || 'Failed to list tmux panes');
|
throw new Error(result.stderr || 'Failed to list tmux panes');
|
||||||
}
|
}
|
||||||
|
|
||||||
const wanted = new Set(normalizedPaneIds);
|
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')) {
|
for (const line of result.stdout.split('\n')) {
|
||||||
const trimmed = line.trim();
|
const trimmed = line.trim();
|
||||||
if (!trimmed) continue;
|
if (!trimmed) continue;
|
||||||
const [paneId = '', rawPid = ''] = trimmed.split('\t');
|
const [
|
||||||
|
paneId = '',
|
||||||
|
rawPid = '',
|
||||||
|
currentCommand = '',
|
||||||
|
currentPath = '',
|
||||||
|
sessionName = '',
|
||||||
|
windowName = '',
|
||||||
|
] = trimmed.split('\t');
|
||||||
const normalizedPaneId = paneId.trim();
|
const normalizedPaneId = paneId.trim();
|
||||||
if (!wanted.has(normalizedPaneId)) continue;
|
if (!wanted.has(normalizedPaneId)) continue;
|
||||||
const pid = Number.parseInt(rawPid.trim(), 10);
|
const pid = Number.parseInt(rawPid.trim(), 10);
|
||||||
if (Number.isFinite(pid) && pid > 0) {
|
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 {
|
killPaneSync(paneId: string): void {
|
||||||
|
|
@ -125,6 +198,29 @@ export class TmuxPlatformCommandExecutor {
|
||||||
return [...candidates];
|
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> {
|
async #resolveNativeTmuxExecutable(env: NodeJS.ProcessEnv): Promise<string> {
|
||||||
const platform =
|
const platform =
|
||||||
process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32'
|
process.platform === 'darwin' || process.platform === 'linux' || process.platform === 'win32'
|
||||||
|
|
|
||||||
|
|
@ -78,7 +78,8 @@ describe('TmuxPlatformCommandExecutor', () => {
|
||||||
);
|
);
|
||||||
vi.spyOn(executor, 'execTmux').mockResolvedValue({
|
vi.spyOn(executor, 'execTmux').mockResolvedValue({
|
||||||
exitCode: 0,
|
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: '',
|
stderr: '',
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -86,8 +87,35 @@ describe('TmuxPlatformCommandExecutor', () => {
|
||||||
new Map([['%2', 222]])
|
new Map([['%2', 222]])
|
||||||
);
|
);
|
||||||
expect(executor.execTmux).toHaveBeenCalledWith(
|
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
|
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);
|
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 {
|
getPersistedPreferredDistroSync(): string | null {
|
||||||
return this.#preferenceStore.getPreferredDistroSync();
|
return this.#preferenceStore.getPreferredDistroSync();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@
|
||||||
process.env.UV_THREADPOOL_SIZE ??= '16';
|
process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||||
|
|
||||||
// Keep userData stable before any integration can initialize Electron storage.
|
// 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.
|
// Sentry must stay near the top to capture early errors after storage migration.
|
||||||
import './sentry';
|
import './sentry';
|
||||||
|
|
||||||
|
|
@ -118,9 +116,6 @@ import {
|
||||||
OpenCodeBridgeCommandHandshakePort,
|
OpenCodeBridgeCommandHandshakePort,
|
||||||
} from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
|
} from './services/team/opencode/bridge/OpenCodeBridgeHandshakeClient';
|
||||||
import { OpenCodeStateChangingBridgeCommandService } from './services/team/opencode/bridge/OpenCodeStateChangingBridgeCommandService';
|
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 { OpenCodeRuntimeManifestEvidenceReader } from './services/team/opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||||
import {
|
import {
|
||||||
buildTeamControlApiBaseUrl,
|
buildTeamControlApiBaseUrl,
|
||||||
|
|
@ -147,6 +142,7 @@ import {
|
||||||
markRendererUnavailable,
|
markRendererUnavailable,
|
||||||
safeSendToRenderer,
|
safeSendToRenderer,
|
||||||
} from './utils/safeWebContentsSend';
|
} from './utils/safeWebContentsSend';
|
||||||
|
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
||||||
import { syncTelemetryFlag } from './sentry';
|
import { syncTelemetryFlag } from './sentry';
|
||||||
import {
|
import {
|
||||||
ActiveTeamRegistry,
|
ActiveTeamRegistry,
|
||||||
|
|
@ -280,13 +276,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
||||||
new OpenCodeTeamRuntimeAdapter(
|
new OpenCodeTeamRuntimeAdapter(
|
||||||
new OpenCodeReadinessBridge(bridgeClient, {
|
new OpenCodeReadinessBridge(bridgeClient, {
|
||||||
stateChangingCommands,
|
stateChangingCommands,
|
||||||
productionE2eEvidence: new OpenCodeProductionE2EEvidenceStore({
|
})
|
||||||
filePath: resolveOpenCodeProductionE2EEvidencePath({ bridgeControlDir }),
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
{
|
|
||||||
launchMode: resolveOpenCodeTeamLaunchModeFromEnv(),
|
|
||||||
}
|
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,6 +64,7 @@ import {
|
||||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||||
|
TEAM_SKIP_MEMBER_FOR_LAUNCH,
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_START_TASK_BY_USER,
|
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_MEMBER_SPAWN_STATUSES, handleMemberSpawnStatuses);
|
||||||
ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime);
|
ipcMain.handle(TEAM_GET_AGENT_RUNTIME, handleGetAgentRuntime);
|
||||||
ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember);
|
ipcMain.handle(TEAM_RESTART_MEMBER, handleRestartMember);
|
||||||
|
ipcMain.handle(TEAM_SKIP_MEMBER_FOR_LAUNCH, handleSkipMemberForLaunch);
|
||||||
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
ipcMain.handle(TEAM_SOFT_DELETE_TASK, handleSoftDeleteTask);
|
||||||
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
ipcMain.handle(TEAM_RESTORE_TASK, handleRestoreTask);
|
||||||
ipcMain.handle(TEAM_GET_DELETED_TASKS, handleGetDeletedTasks);
|
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_MEMBER_SPAWN_STATUSES);
|
||||||
ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME);
|
ipcMain.removeHandler(TEAM_GET_AGENT_RUNTIME);
|
||||||
ipcMain.removeHandler(TEAM_RESTART_MEMBER);
|
ipcMain.removeHandler(TEAM_RESTART_MEMBER);
|
||||||
|
ipcMain.removeHandler(TEAM_SKIP_MEMBER_FOR_LAUNCH);
|
||||||
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
ipcMain.removeHandler(TEAM_SOFT_DELETE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
ipcMain.removeHandler(TEAM_RESTORE_TASK);
|
||||||
ipcMain.removeHandler(TEAM_GET_DELETED_TASKS);
|
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(
|
async function handleStopTeam(
|
||||||
_event: IpcMainInvokeEvent,
|
_event: IpcMainInvokeEvent,
|
||||||
teamName: unknown
|
teamName: unknown
|
||||||
|
|
|
||||||
|
|
@ -570,6 +570,7 @@ export class ConfigManager {
|
||||||
...DEFAULT_CONFIG.general,
|
...DEFAULT_CONFIG.general,
|
||||||
...(loaded.general ?? {}),
|
...(loaded.general ?? {}),
|
||||||
};
|
};
|
||||||
|
mergedGeneral.multimodelEnabled = true;
|
||||||
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
|
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
|
||||||
|
|
||||||
// Merge triggers: preserve existing triggers, add missing builtin ones
|
// 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
|
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. */
|
/** Bound best-effort catch-up work per tick so it cannot monopolize the event loop. */
|
||||||
const CATCH_UP_SCAN_BUDGET = 24;
|
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 {
|
interface AppendedParseResult {
|
||||||
messages: ParsedMessage[];
|
messages: ParsedMessage[];
|
||||||
|
|
@ -93,6 +95,8 @@ export class FileWatcher extends EventEmitter {
|
||||||
private catchUpInProgress = false;
|
private catchUpInProgress = false;
|
||||||
/** Round-robin cursor so catch-up work is spread across tracked files. */
|
/** Round-robin cursor so catch-up work is spread across tracked files. */
|
||||||
private catchUpCursor = 0;
|
private catchUpCursor = 0;
|
||||||
|
/** Consecutive catch-up stat timeouts per file. */
|
||||||
|
private catchUpStatFailures = new Map<string, number>();
|
||||||
/** Timer for SSH polling mode (replaces fs.watch) */
|
/** Timer for SSH polling mode (replaces fs.watch) */
|
||||||
private pollingTimer: NodeJS.Timeout | null = null;
|
private pollingTimer: NodeJS.Timeout | null = null;
|
||||||
/** Polling interval for SSH mode */
|
/** Polling interval for SSH mode */
|
||||||
|
|
@ -232,6 +236,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.lastProcessedLineCount.clear();
|
this.lastProcessedLineCount.clear();
|
||||||
this.lastProcessedSize.clear();
|
this.lastProcessedSize.clear();
|
||||||
this.activeSessionFiles.clear();
|
this.activeSessionFiles.clear();
|
||||||
|
this.catchUpStatFailures.clear();
|
||||||
this.processingInProgress.clear();
|
this.processingInProgress.clear();
|
||||||
this.pendingReprocess.clear();
|
this.pendingReprocess.clear();
|
||||||
|
|
||||||
|
|
@ -284,6 +289,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.lastProcessedLineCount.clear();
|
this.lastProcessedLineCount.clear();
|
||||||
this.lastProcessedSize.clear();
|
this.lastProcessedSize.clear();
|
||||||
this.activeSessionFiles.clear();
|
this.activeSessionFiles.clear();
|
||||||
|
this.catchUpStatFailures.clear();
|
||||||
this.polledFileSizes.clear();
|
this.polledFileSizes.clear();
|
||||||
this.processingInProgress.clear();
|
this.processingInProgress.clear();
|
||||||
this.pendingReprocess.clear();
|
this.pendingReprocess.clear();
|
||||||
|
|
@ -867,6 +873,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.lastProcessedLineCount.delete(filePath);
|
this.lastProcessedLineCount.delete(filePath);
|
||||||
this.lastProcessedSize.delete(filePath);
|
this.lastProcessedSize.delete(filePath);
|
||||||
this.activeSessionFiles.delete(filePath);
|
this.activeSessionFiles.delete(filePath);
|
||||||
|
this.catchUpStatFailures.delete(filePath);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -876,6 +883,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
this.lastProcessedLineCount.clear();
|
this.lastProcessedLineCount.clear();
|
||||||
this.lastProcessedSize.clear();
|
this.lastProcessedSize.clear();
|
||||||
this.activeSessionFiles.clear();
|
this.activeSessionFiles.clear();
|
||||||
|
this.catchUpStatFailures.clear();
|
||||||
this.catchUpCursor = 0;
|
this.catchUpCursor = 0;
|
||||||
this.catchUpInProgress = false;
|
this.catchUpInProgress = false;
|
||||||
}
|
}
|
||||||
|
|
@ -1119,6 +1127,7 @@ export class FileWatcher extends EventEmitter {
|
||||||
}
|
}
|
||||||
|
|
||||||
const stats = await this.fsProvider.stat(filePath);
|
const stats = await this.fsProvider.stat(filePath);
|
||||||
|
this.catchUpStatFailures.delete(filePath);
|
||||||
|
|
||||||
// Skip files not modified recently
|
// Skip files not modified recently
|
||||||
if (now - stats.mtimeMs > CATCH_UP_MAX_AGE_MS) {
|
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
|
// File may have been deleted between iterations
|
||||||
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
if ((err as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
this.clearErrorTracking(filePath);
|
this.clearErrorTracking(filePath);
|
||||||
|
} else if (this.isStatTimeoutError(err)) {
|
||||||
|
this.handleCatchUpStatTimeout(filePath);
|
||||||
} else {
|
} else {
|
||||||
logger.error(`FileWatcher: Error during catch-up stat for ${filePath}:`, err);
|
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
|
// Debouncing
|
||||||
// ===========================================================================
|
// ===========================================================================
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { once } from 'node:events';
|
|
||||||
import readline from 'node:readline';
|
import readline from 'node:readline';
|
||||||
|
|
||||||
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
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_REQUEST_TIMEOUT_MS = 3_000;
|
||||||
const DEFAULT_TOTAL_TIMEOUT_MS = 8_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 {
|
export class JsonRpcRequestError extends Error {
|
||||||
readonly code: number | null;
|
readonly code: number | null;
|
||||||
|
|
@ -76,6 +78,9 @@ export class JsonRpcStdioClient {
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
totalTimeoutMs?: number;
|
totalTimeoutMs?: number;
|
||||||
|
stdinCloseTimeoutMs?: number;
|
||||||
|
closeTimeoutMs?: number;
|
||||||
|
forceCloseTimeoutMs?: number;
|
||||||
label: string;
|
label: string;
|
||||||
},
|
},
|
||||||
handler: (session: JsonRpcSession) => Promise<T>
|
handler: (session: JsonRpcSession) => Promise<T>
|
||||||
|
|
@ -95,8 +100,14 @@ export class JsonRpcStdioClient {
|
||||||
args: string[];
|
args: string[];
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
|
stdinCloseTimeoutMs?: number;
|
||||||
|
closeTimeoutMs?: number;
|
||||||
|
forceCloseTimeoutMs?: number;
|
||||||
}): Promise<JsonRpcSession> {
|
}): Promise<JsonRpcSession> {
|
||||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
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, {
|
const child = spawnCli(options.binaryPath, options.args, {
|
||||||
env: options.env,
|
env: options.env,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
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> => {
|
const close = async (): Promise<void> => {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -204,21 +267,26 @@ export class JsonRpcStdioClient {
|
||||||
notificationListeners.clear();
|
notificationListeners.clear();
|
||||||
lineReader.close();
|
lineReader.close();
|
||||||
|
|
||||||
if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) {
|
await closeStdin();
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
try {
|
const gracefulClose = waitForChildClose(closeTimeoutMs);
|
||||||
child.stdin!.end(() => resolve());
|
killProcessTree(child, 'SIGTERM');
|
||||||
} catch {
|
if (await gracefulClose) {
|
||||||
resolve();
|
return;
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
killProcessTree(child);
|
this.logger.warn('json-rpc close timed out; force killing process', {
|
||||||
try {
|
pid: child.pid,
|
||||||
await once(child, 'close');
|
timeoutMs: closeTimeoutMs,
|
||||||
} catch {
|
});
|
||||||
this.logger.warn('json-rpc close wait failed');
|
|
||||||
|
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 os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { JsonRpcStdioClient } from '../JsonRpcStdioClient';
|
import { JsonRpcStdioClient } from '../JsonRpcStdioClient';
|
||||||
|
|
||||||
|
|
@ -42,6 +42,35 @@ rl.on('line', (line) => {
|
||||||
return scriptPath;
|
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(() => {
|
afterEach(() => {
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
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 status = typeof raw.status === 'string' ? raw.status : 'pending';
|
||||||
const hardFailure = status === 'failed';
|
const hardFailure = status === 'failed';
|
||||||
const bootstrapConfirmed = status === 'bootstrap_confirmed';
|
const bootstrapConfirmed = status === 'bootstrap_confirmed';
|
||||||
const runtimeAlive = bootstrapConfirmed || status === 'runtime_alive';
|
const bootstrapReportedRuntimeAlive = status === 'runtime_alive';
|
||||||
|
const runtimeAlive = bootstrapConfirmed;
|
||||||
const agentToolAccepted =
|
const agentToolAccepted =
|
||||||
bootstrapConfirmed ||
|
bootstrapConfirmed ||
|
||||||
runtimeAlive ||
|
bootstrapReportedRuntimeAlive ||
|
||||||
status === 'registered' ||
|
status === 'registered' ||
|
||||||
status === 'spawn_started' ||
|
status === 'spawn_started' ||
|
||||||
hardFailure;
|
hardFailure;
|
||||||
|
|
@ -351,7 +352,7 @@ function normalizeBootstrapMemberState(
|
||||||
? 'failed_to_start'
|
? 'failed_to_start'
|
||||||
: bootstrapConfirmed
|
: bootstrapConfirmed
|
||||||
? 'confirmed_alive'
|
? 'confirmed_alive'
|
||||||
: runtimeAlive || agentToolAccepted
|
: agentToolAccepted
|
||||||
? 'runtime_pending_bootstrap'
|
? 'runtime_pending_bootstrap'
|
||||||
: 'starting',
|
: 'starting',
|
||||||
agentToolAccepted,
|
agentToolAccepted,
|
||||||
|
|
@ -381,10 +382,10 @@ function normalizeBootstrapMemberState(
|
||||||
? raw.failureReason.trim()
|
? raw.failureReason.trim()
|
||||||
: 'deterministic bootstrap failed',
|
: 'deterministic bootstrap failed',
|
||||||
]
|
]
|
||||||
: runtimeAlive
|
: bootstrapConfirmed
|
||||||
? bootstrapConfirmed
|
|
||||||
? ['late heartbeat received']
|
? ['late heartbeat received']
|
||||||
: ['runtime alive', 'waiting for teammate check-in']
|
: bootstrapReportedRuntimeAlive
|
||||||
|
? ['runtime alive reported by bootstrap state', 'waiting for strict live verification']
|
||||||
: agentToolAccepted
|
: agentToolAccepted
|
||||||
? ['spawn accepted']
|
? ['spawn accepted']
|
||||||
: undefined,
|
: undefined,
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
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 { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
|
|
@ -265,6 +265,11 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null {
|
||||||
return body.length > 0 ? body : 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 {
|
function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
|
||||||
return members.some((member) => {
|
return members.some((member) => {
|
||||||
if (isLeadMember(member)) {
|
if (isLeadMember(member)) {
|
||||||
|
|
@ -274,7 +279,7 @@ function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
|
||||||
if (normalizedName === 'lead') {
|
if (normalizedName === 'lead') {
|
||||||
return true;
|
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') {
|
if (normalizedName === 'lead') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return member.role?.toLowerCase().includes('lead') === true;
|
return isExplicitLeadRole(member.role);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -530,16 +535,22 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveTaskReviewState(
|
private resolveTaskReviewState(
|
||||||
task: Pick<TeamTask, 'reviewState'>
|
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
|
||||||
|
kanbanTaskState?: KanbanState['tasks'][string]
|
||||||
): 'none' | 'review' | 'needsFix' | 'approved' {
|
): 'none' | 'review' | 'needsFix' | 'approved' {
|
||||||
return normalizeReviewState(task.reviewState);
|
return getReviewStateFromTask({
|
||||||
|
historyEvents: task.historyEvents,
|
||||||
|
reviewState: task.reviewState,
|
||||||
|
status: task.status,
|
||||||
|
kanbanColumn: kanbanTaskState?.column,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachKanbanCompatibility(
|
private attachKanbanCompatibility(
|
||||||
task: TeamTask,
|
task: TeamTask,
|
||||||
kanbanTaskState?: KanbanState['tasks'][string]
|
kanbanTaskState?: KanbanState['tasks'][string]
|
||||||
): TeamTaskWithKanban {
|
): TeamTaskWithKanban {
|
||||||
const reviewState = this.resolveTaskReviewState(task);
|
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
||||||
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
|
|
@ -557,8 +568,15 @@ export class TeamDataService {
|
||||||
private resolveReviewerFromHistory(
|
private resolveReviewerFromHistory(
|
||||||
task: TeamTask,
|
task: TeamTask,
|
||||||
kanbanTaskState?: KanbanState['tasks'][string],
|
kanbanTaskState?: KanbanState['tasks'][string],
|
||||||
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(task)
|
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(
|
||||||
|
task,
|
||||||
|
kanbanTaskState
|
||||||
|
)
|
||||||
): string | null {
|
): string | null {
|
||||||
|
if (reviewState !== 'review') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (task.historyEvents?.length) {
|
if (task.historyEvents?.length) {
|
||||||
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
|
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
|
||||||
const event = task.historyEvents[i];
|
const event = task.historyEvents[i];
|
||||||
|
|
@ -571,7 +589,10 @@ export class TeamDataService {
|
||||||
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
|
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
|
||||||
break;
|
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;
|
break;
|
||||||
}
|
}
|
||||||
if (event.type === 'task_created') {
|
if (event.type === 'task_created') {
|
||||||
|
|
@ -894,7 +915,8 @@ export class TeamDataService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const info = teamInfoMap.get(task.teamName)!;
|
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);
|
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
|
||||||
|
|
||||||
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
// 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 {
|
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
||||||
if (!config) return 'team-lead';
|
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';
|
return lead?.name ?? config.members?.[0]?.name ?? 'team-lead';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2731,9 +2757,9 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
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, {
|
this.getController(teamName).review.requestReview(taskId, {
|
||||||
from: 'user',
|
from: leadName,
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -3196,15 +3222,15 @@ export class TeamDataService {
|
||||||
|
|
||||||
if (patch.op === 'set_column') {
|
if (patch.op === 'set_column') {
|
||||||
if (patch.column === 'review') {
|
if (patch.column === 'review') {
|
||||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||||
controller.review.requestReview(taskId, {
|
controller.review.requestReview(taskId, {
|
||||||
from: 'user',
|
from: leadName,
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||||
controller.review.approveReview(taskId, {
|
controller.review.approveReview(taskId, {
|
||||||
from: 'user',
|
from: leadName,
|
||||||
suppressTaskComment: true,
|
suppressTaskComment: true,
|
||||||
'notify-owner': true,
|
'notify-owner': true,
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
|
@ -3213,9 +3239,9 @@ export class TeamDataService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||||
controller.review.requestChanges(taskId, {
|
controller.review.requestChanges(taskId, {
|
||||||
from: 'user',
|
from: leadName,
|
||||||
comment: patch.comment?.trim() || 'Reviewer requested changes.',
|
comment: patch.comment?.trim() || 'Reviewer requested changes.',
|
||||||
...(patch.op === 'request_changes' && patch.taskRefs?.length
|
...(patch.op === 'request_changes' && patch.taskRefs?.length
|
||||||
? { taskRefs: patch.taskRefs }
|
? { taskRefs: patch.taskRefs }
|
||||||
|
|
|
||||||
|
|
@ -12,6 +12,9 @@ import type {
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
PersistedTeamLaunchSummary,
|
PersistedTeamLaunchSummary,
|
||||||
ProviderModelLaunchIdentity,
|
ProviderModelLaunchIdentity,
|
||||||
|
TeamAgentRuntimeDiagnosticSeverity,
|
||||||
|
TeamAgentRuntimeLivenessKind,
|
||||||
|
TeamAgentRuntimePidSource,
|
||||||
TeamLaunchAggregateState,
|
TeamLaunchAggregateState,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -36,9 +39,17 @@ type RuntimeMemberSpawnState = Pick<
|
||||||
| 'runtimeAlive'
|
| 'runtimeAlive'
|
||||||
| 'bootstrapConfirmed'
|
| 'bootstrapConfirmed'
|
||||||
| 'hardFailure'
|
| 'hardFailure'
|
||||||
|
| 'skippedForLaunch'
|
||||||
|
| 'skipReason'
|
||||||
|
| 'skippedAt'
|
||||||
| 'pendingPermissionRequestIds'
|
| 'pendingPermissionRequestIds'
|
||||||
|
| 'livenessKind'
|
||||||
|
| 'runtimeDiagnostic'
|
||||||
|
| 'runtimeDiagnosticSeverity'
|
||||||
|
| 'livenessLastCheckedAt'
|
||||||
| 'firstSpawnAcceptedAt'
|
| 'firstSpawnAcceptedAt'
|
||||||
| 'lastHeartbeatAt'
|
| 'lastHeartbeatAt'
|
||||||
|
| 'runtimeModel'
|
||||||
| 'updatedAt'
|
| 'updatedAt'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
|
|
@ -59,6 +70,58 @@ function normalizeRuntimePid(value: unknown): number | undefined {
|
||||||
: 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 {
|
function normalizeMemberName(name: string): string {
|
||||||
return name.trim();
|
return name.trim();
|
||||||
}
|
}
|
||||||
|
|
@ -70,6 +133,8 @@ function buildDiagnostics(
|
||||||
| 'runtimeAlive'
|
| 'runtimeAlive'
|
||||||
| 'bootstrapConfirmed'
|
| 'bootstrapConfirmed'
|
||||||
| 'hardFailureReason'
|
| 'hardFailureReason'
|
||||||
|
| 'skippedForLaunch'
|
||||||
|
| 'skipReason'
|
||||||
| 'sources'
|
| 'sources'
|
||||||
| 'pendingPermissionRequestIds'
|
| 'pendingPermissionRequestIds'
|
||||||
>
|
>
|
||||||
|
|
@ -85,6 +150,13 @@ function buildDiagnostics(
|
||||||
}
|
}
|
||||||
if (member.hardFailureReason)
|
if (member.hardFailureReason)
|
||||||
diagnostics.push(`hard failure reason: ${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?.duplicateRespawnBlocked) diagnostics.push('respawn blocked as duplicate');
|
||||||
if (member.sources?.configDrift) diagnostics.push('config drift detected');
|
if (member.sources?.configDrift) diagnostics.push('config drift detected');
|
||||||
return diagnostics;
|
return diagnostics;
|
||||||
|
|
@ -99,6 +171,9 @@ export function deriveTeamLaunchAggregateState(
|
||||||
if (summary.pendingCount > 0) {
|
if (summary.pendingCount > 0) {
|
||||||
return 'partial_pending';
|
return 'partial_pending';
|
||||||
}
|
}
|
||||||
|
if ((summary.skippedCount ?? 0) > 0) {
|
||||||
|
return 'partial_skipped';
|
||||||
|
}
|
||||||
return 'clean_success';
|
return 'clean_success';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -109,7 +184,13 @@ export function summarizePersistedLaunchMembers(
|
||||||
let confirmedCount = 0;
|
let confirmedCount = 0;
|
||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
|
let skippedCount = 0;
|
||||||
let runtimeAlivePendingCount = 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 normalizedExpected = expectedMembers.map(normalizeMemberName).filter(Boolean);
|
||||||
const memberNames = Array.from(
|
const memberNames = Array.from(
|
||||||
new Set([
|
new Set([
|
||||||
|
|
@ -128,17 +209,48 @@ export function summarizePersistedLaunchMembers(
|
||||||
confirmedCount += 1;
|
confirmedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||||
|
skippedCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (entry.launchState === 'failed_to_start') {
|
if (entry.launchState === 'failed_to_start') {
|
||||||
failedCount += 1;
|
failedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
pendingCount += 1;
|
pendingCount += 1;
|
||||||
if (entry.runtimeAlive) {
|
if (preservesStrongRuntimeAlive(entry)) {
|
||||||
runtimeAlivePendingCount += 1;
|
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(
|
export function hasMixedPersistedLaunchMetadata(
|
||||||
|
|
@ -169,9 +281,13 @@ function deriveMemberLaunchState(
|
||||||
| 'bootstrapConfirmed'
|
| 'bootstrapConfirmed'
|
||||||
| 'runtimeAlive'
|
| 'runtimeAlive'
|
||||||
| 'agentToolAccepted'
|
| 'agentToolAccepted'
|
||||||
|
| 'skippedForLaunch'
|
||||||
| 'pendingPermissionRequestIds'
|
| 'pendingPermissionRequestIds'
|
||||||
>
|
>
|
||||||
): MemberLaunchState {
|
): MemberLaunchState {
|
||||||
|
if (member.skippedForLaunch) {
|
||||||
|
return 'skipped_for_launch';
|
||||||
|
}
|
||||||
if (member.hardFailure) {
|
if (member.hardFailure) {
|
||||||
return 'failed_to_start';
|
return 'failed_to_start';
|
||||||
}
|
}
|
||||||
|
|
@ -299,6 +415,23 @@ function normalizePersistedMemberState(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const providerId = normalizeOptionalTeamProviderId(parsed.providerId);
|
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 = {
|
const next: PersistedTeamLaunchMemberState = {
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
providerId,
|
providerId,
|
||||||
|
|
@ -328,18 +461,28 @@ function normalizePersistedMemberState(
|
||||||
laneOwnerProviderId: normalizeOptionalTeamProviderId(parsed.laneOwnerProviderId),
|
laneOwnerProviderId: normalizeOptionalTeamProviderId(parsed.laneOwnerProviderId),
|
||||||
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
|
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
|
||||||
launchState: 'starting',
|
launchState: 'starting',
|
||||||
agentToolAccepted: toBoolean(parsed.agentToolAccepted),
|
skippedForLaunch,
|
||||||
runtimeAlive: toBoolean(parsed.runtimeAlive),
|
skipReason: normalizeOptionalString(parsed.skipReason),
|
||||||
bootstrapConfirmed: toBoolean(parsed.bootstrapConfirmed),
|
skippedAt: normalizeOptionalString(parsed.skippedAt),
|
||||||
hardFailure: toBoolean(parsed.hardFailure),
|
agentToolAccepted: skippedForLaunch ? false : toBoolean(parsed.agentToolAccepted),
|
||||||
hardFailureReason:
|
runtimeAlive,
|
||||||
typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
bootstrapConfirmed,
|
||||||
|
hardFailure: skippedForLaunch ? false : toBoolean(parsed.hardFailure),
|
||||||
|
hardFailureReason: skippedForLaunch
|
||||||
|
? undefined
|
||||||
|
: typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
||||||
? parsed.hardFailureReason.trim()
|
? parsed.hardFailureReason.trim()
|
||||||
: undefined,
|
: undefined,
|
||||||
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
|
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
|
||||||
parsed.pendingPermissionRequestIds
|
parsed.pendingPermissionRequestIds
|
||||||
),
|
),
|
||||||
runtimePid: normalizeRuntimePid(parsed.runtimePid),
|
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:
|
firstSpawnAcceptedAt:
|
||||||
typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined,
|
typeof parsed.firstSpawnAcceptedAt === 'string' ? parsed.firstSpawnAcceptedAt : undefined,
|
||||||
lastHeartbeatAt:
|
lastHeartbeatAt:
|
||||||
|
|
@ -348,7 +491,7 @@ function normalizePersistedMemberState(
|
||||||
typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined,
|
typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined,
|
||||||
lastEvaluatedAt:
|
lastEvaluatedAt:
|
||||||
typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback,
|
typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback,
|
||||||
sources: normalizeSources(parsed.sources),
|
sources: Object.values(sources).some(Boolean) ? sources : undefined,
|
||||||
diagnostics: Array.isArray(parsed.diagnostics)
|
diagnostics: Array.isArray(parsed.diagnostics)
|
||||||
? parsed.diagnostics.filter(
|
? parsed.diagnostics.filter(
|
||||||
(item): item is string => typeof item === 'string' && item.trim().length > 0
|
(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_bootstrap' ||
|
||||||
parsed.launchState === 'runtime_pending_permission' ||
|
parsed.launchState === 'runtime_pending_permission' ||
|
||||||
parsed.launchState === 'confirmed_alive' ||
|
parsed.launchState === 'confirmed_alive' ||
|
||||||
parsed.launchState === 'failed_to_start'
|
parsed.launchState === 'failed_to_start' ||
|
||||||
|
parsed.launchState === 'skipped_for_launch'
|
||||||
? parsed.launchState
|
? parsed.launchState
|
||||||
: deriveMemberLaunchState(next);
|
: deriveMemberLaunchState(next);
|
||||||
next.launchState = launchState;
|
next.launchState = launchState;
|
||||||
|
|
@ -402,6 +546,7 @@ export function createPersistedLaunchSnapshot(params: {
|
||||||
members[name] = {
|
members[name] = {
|
||||||
name,
|
name,
|
||||||
launchState: 'starting',
|
launchState: 'starting',
|
||||||
|
skippedForLaunch: false,
|
||||||
agentToolAccepted: false,
|
agentToolAccepted: false,
|
||||||
runtimeAlive: false,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
|
|
@ -478,23 +623,39 @@ export function snapshotFromRuntimeMemberStatuses(params: {
|
||||||
sources.nativeHeartbeat = true;
|
sources.nativeHeartbeat = true;
|
||||||
sources.inboxHeartbeat = 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;
|
sources.processAlive = true;
|
||||||
}
|
}
|
||||||
const entry: PersistedTeamLaunchMemberState = {
|
const entry: PersistedTeamLaunchMemberState = {
|
||||||
name,
|
name,
|
||||||
launchState: runtime?.launchState ?? 'starting',
|
launchState: runtime?.launchState ?? 'starting',
|
||||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
skippedForLaunch,
|
||||||
runtimeAlive: runtime?.runtimeAlive === true,
|
skipReason: runtime?.skipReason,
|
||||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
skippedAt: runtime?.skippedAt,
|
||||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
agentToolAccepted: skippedForLaunch ? false : runtime?.agentToolAccepted === true,
|
||||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
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
|
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||||
: undefined,
|
: undefined,
|
||||||
|
livenessKind: runtime?.livenessKind,
|
||||||
|
runtimeDiagnostic: runtime?.runtimeDiagnostic,
|
||||||
|
runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
|
||||||
|
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
|
||||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined,
|
lastRuntimeAliveAt: runtimeAlive ? updatedAt : undefined,
|
||||||
lastEvaluatedAt: runtime?.updatedAt ?? updatedAt,
|
lastEvaluatedAt: runtime?.updatedAt ?? updatedAt,
|
||||||
sources: Object.values(sources).some(Boolean) ? sources : undefined,
|
sources: Object.values(sources).some(Boolean) ? sources : undefined,
|
||||||
diagnostics: undefined,
|
diagnostics: undefined,
|
||||||
|
|
@ -530,8 +691,13 @@ export function snapshotToMemberSpawnStatuses(
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
let status: MemberSpawnStatusEntry['status'] = 'offline';
|
let status: MemberSpawnStatusEntry['status'] = 'offline';
|
||||||
let livenessSource: MemberSpawnLivenessSource | undefined;
|
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') {
|
if (entry.launchState === 'failed_to_start') {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
|
} else if (entry.launchState === 'skipped_for_launch') {
|
||||||
|
status = 'skipped';
|
||||||
} else if (entry.launchState === 'confirmed_alive') {
|
} else if (entry.launchState === 'confirmed_alive') {
|
||||||
status = 'online';
|
status = 'online';
|
||||||
livenessSource = 'heartbeat';
|
livenessSource = 'heartbeat';
|
||||||
|
|
@ -539,8 +705,8 @@ export function snapshotToMemberSpawnStatuses(
|
||||||
entry.launchState === 'runtime_pending_permission' ||
|
entry.launchState === 'runtime_pending_permission' ||
|
||||||
entry.launchState === 'runtime_pending_bootstrap'
|
entry.launchState === 'runtime_pending_bootstrap'
|
||||||
) {
|
) {
|
||||||
status = entry.runtimeAlive ? 'online' : 'waiting';
|
status = runtimeAlive ? 'online' : 'waiting';
|
||||||
livenessSource = entry.runtimeAlive ? 'process' : undefined;
|
livenessSource = runtimeAlive ? 'process' : undefined;
|
||||||
} else {
|
} else {
|
||||||
status = entry.agentToolAccepted ? 'waiting' : 'spawning';
|
status = entry.agentToolAccepted ? 'waiting' : 'spawning';
|
||||||
}
|
}
|
||||||
|
|
@ -549,12 +715,19 @@ export function snapshotToMemberSpawnStatuses(
|
||||||
launchState: entry.launchState,
|
launchState: entry.launchState,
|
||||||
error: entry.hardFailure ? entry.hardFailureReason : undefined,
|
error: entry.hardFailure ? entry.hardFailureReason : undefined,
|
||||||
hardFailureReason: entry.hardFailureReason,
|
hardFailureReason: entry.hardFailureReason,
|
||||||
|
skippedForLaunch: entry.skippedForLaunch,
|
||||||
|
skipReason: entry.skipReason,
|
||||||
|
skippedAt: entry.skippedAt,
|
||||||
livenessSource,
|
livenessSource,
|
||||||
agentToolAccepted: entry.agentToolAccepted,
|
agentToolAccepted: skippedForLaunch ? false : entry.agentToolAccepted,
|
||||||
runtimeAlive: entry.runtimeAlive,
|
runtimeAlive,
|
||||||
bootstrapConfirmed: entry.bootstrapConfirmed,
|
bootstrapConfirmed: skippedForLaunch ? false : entry.bootstrapConfirmed,
|
||||||
hardFailure: entry.hardFailure,
|
hardFailure: skippedForLaunch ? false : entry.hardFailure,
|
||||||
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
||||||
|
livenessKind: entry.livenessKind,
|
||||||
|
runtimeDiagnostic: entry.runtimeDiagnostic,
|
||||||
|
runtimeDiagnosticSeverity: entry.runtimeDiagnosticSeverity,
|
||||||
|
livenessLastCheckedAt: entry.runtimeLastSeenAt ?? entry.lastEvaluatedAt,
|
||||||
firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt,
|
firstSpawnAcceptedAt: entry.firstSpawnAcceptedAt,
|
||||||
lastHeartbeatAt: entry.lastHeartbeatAt,
|
lastHeartbeatAt: entry.lastHeartbeatAt,
|
||||||
updatedAt: entry.lastEvaluatedAt,
|
updatedAt: entry.lastEvaluatedAt,
|
||||||
|
|
|
||||||
|
|
@ -13,12 +13,19 @@ export interface LaunchStateSummary {
|
||||||
expectedMemberCount?: number;
|
expectedMemberCount?: number;
|
||||||
confirmedMemberCount?: number;
|
confirmedMemberCount?: number;
|
||||||
missingMembers?: string[];
|
missingMembers?: string[];
|
||||||
|
skippedMembers?: string[];
|
||||||
teamLaunchState?: TeamSummary['teamLaunchState'];
|
teamLaunchState?: TeamSummary['teamLaunchState'];
|
||||||
launchUpdatedAt?: string;
|
launchUpdatedAt?: string;
|
||||||
confirmedCount?: number;
|
confirmedCount?: number;
|
||||||
pendingCount?: number;
|
pendingCount?: number;
|
||||||
failedCount?: number;
|
failedCount?: number;
|
||||||
|
skippedCount?: number;
|
||||||
runtimeAlivePendingCount?: number;
|
runtimeAlivePendingCount?: number;
|
||||||
|
shellOnlyPendingCount?: number;
|
||||||
|
runtimeProcessPendingCount?: number;
|
||||||
|
runtimeCandidatePendingCount?: number;
|
||||||
|
noRuntimePendingCount?: number;
|
||||||
|
permissionPendingCount?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary {
|
export interface PersistedTeamLaunchSummaryProjection extends LaunchStateSummary {
|
||||||
|
|
@ -55,6 +62,10 @@ export function createLaunchStateSummary(
|
||||||
const member = snapshot.members[name];
|
const member = snapshot.members[name];
|
||||||
return member?.launchState === 'failed_to_start';
|
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 {
|
return {
|
||||||
...(snapshot.teamLaunchState === 'partial_failure'
|
...(snapshot.teamLaunchState === 'partial_failure'
|
||||||
|
|
@ -67,12 +78,19 @@ export function createLaunchStateSummary(
|
||||||
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
? { confirmedMemberCount: snapshot.summary.confirmedCount }
|
||||||
: {}),
|
: {}),
|
||||||
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
...(missingMembers.length > 0 ? { missingMembers } : {}),
|
||||||
|
...(skippedMembers.length > 0 ? { skippedMembers } : {}),
|
||||||
teamLaunchState: snapshot.teamLaunchState,
|
teamLaunchState: snapshot.teamLaunchState,
|
||||||
launchUpdatedAt: snapshot.updatedAt,
|
launchUpdatedAt: snapshot.updatedAt,
|
||||||
confirmedCount: snapshot.summary.confirmedCount,
|
confirmedCount: snapshot.summary.confirmedCount,
|
||||||
pendingCount: snapshot.summary.pendingCount,
|
pendingCount: snapshot.summary.pendingCount,
|
||||||
failedCount: snapshot.summary.failedCount,
|
failedCount: snapshot.summary.failedCount,
|
||||||
|
skippedCount: snapshot.summary.skippedCount,
|
||||||
runtimeAlivePendingCount: snapshot.summary.runtimeAlivePendingCount,
|
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;
|
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 (
|
if (
|
||||||
record.teamLaunchState === 'partial_failure' ||
|
record.teamLaunchState === 'partial_failure' ||
|
||||||
|
record.teamLaunchState === 'partial_skipped' ||
|
||||||
record.teamLaunchState === 'partial_pending' ||
|
record.teamLaunchState === 'partial_pending' ||
|
||||||
record.teamLaunchState === 'clean_success'
|
record.teamLaunchState === 'clean_success'
|
||||||
) {
|
) {
|
||||||
|
|
@ -144,9 +171,33 @@ export function normalizePersistedLaunchSummaryProjection(
|
||||||
if (typeof record.failedCount === 'number' && record.failedCount >= 0) {
|
if (typeof record.failedCount === 'number' && record.failedCount >= 0) {
|
||||||
normalized.failedCount = record.failedCount;
|
normalized.failedCount = record.failedCount;
|
||||||
}
|
}
|
||||||
|
if (typeof record.skippedCount === 'number' && record.skippedCount >= 0) {
|
||||||
|
normalized.skippedCount = record.skippedCount;
|
||||||
|
}
|
||||||
if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) {
|
if (typeof record.runtimeAlivePendingCount === 'number' && record.runtimeAlivePendingCount >= 0) {
|
||||||
normalized.runtimeAlivePendingCount = record.runtimeAlivePendingCount;
|
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;
|
normalized.launchUpdatedAt = updatedAt;
|
||||||
return normalized;
|
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`);
|
logger.warn(`Packaged MCP entry not found at ${packagedEntry}, falling back to workspace`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Dev mode — prefer built dist for reliable direct execution
|
// 2. Dev mode — prefer source so pnpm dev always sees current MCP tools
|
||||||
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
|
|
||||||
const sourceEntry = getSourceServerEntry();
|
const sourceEntry = getSourceServerEntry();
|
||||||
checked.push(sourceEntry);
|
checked.push(sourceEntry);
|
||||||
if (await pathExists(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(
|
throw new Error(
|
||||||
`agent-teams-mcp entrypoint not found. Checked paths:\n${checked.map((p) => ` - ${p}`).join('\n')}`
|
`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,
|
completedAt: i.completedAt,
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: 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 = {
|
const task: TeamTask = {
|
||||||
id:
|
id:
|
||||||
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||||
|
|
@ -192,11 +197,7 @@ export class TeamTaskReader {
|
||||||
promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs),
|
promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs),
|
||||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||||
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
status,
|
||||||
parsed.status as TeamTask['status']
|
|
||||||
)
|
|
||||||
? (parsed.status as TeamTask['status'])
|
|
||||||
: 'pending',
|
|
||||||
workIntervals,
|
workIntervals,
|
||||||
historyEvents,
|
historyEvents,
|
||||||
blocks: Array.isArray(parsed.blocks)
|
blocks: Array.isArray(parsed.blocks)
|
||||||
|
|
@ -299,6 +300,7 @@ export class TeamTaskReader {
|
||||||
reviewState: getReviewStateFromTask({
|
reviewState: getReviewStateFromTask({
|
||||||
historyEvents,
|
historyEvents,
|
||||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||||
|
status,
|
||||||
}),
|
}),
|
||||||
sourceMessageId:
|
sourceMessageId:
|
||||||
typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim()
|
typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim()
|
||||||
|
|
@ -413,6 +415,7 @@ export class TeamTaskReader {
|
||||||
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
||||||
reviewState: getReviewStateFromTask({
|
reviewState: getReviewStateFromTask({
|
||||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||||
|
status: 'deleted',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { configManager } from '../infrastructure/ConfigManager';
|
|
||||||
|
|
||||||
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
|
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
|
||||||
|
|
||||||
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator';
|
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator';
|
||||||
|
|
@ -18,8 +16,7 @@ export function getConfiguredCliFlavor(): CliFlavor {
|
||||||
return envOverride;
|
return envOverride;
|
||||||
}
|
}
|
||||||
|
|
||||||
const multimodelEnabled = configManager.getConfig().general.multimodelEnabled;
|
return DEFAULT_CLI_FLAVOR;
|
||||||
return multimodelEnabled ? 'agent_teams_orchestrator' : 'claude';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
|
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
|
||||||
|
|
|
||||||
|
|
@ -22,8 +22,6 @@ export type {
|
||||||
export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge';
|
export { OpenCodeReadinessBridge } from './opencode/bridge/OpenCodeReadinessBridge';
|
||||||
export { ReviewApplierService } from './ReviewApplierService';
|
export { ReviewApplierService } from './ReviewApplierService';
|
||||||
export type {
|
export type {
|
||||||
OpenCodeTeamLaunchMode,
|
|
||||||
OpenCodeTeamRuntimeAdapterOptions,
|
|
||||||
OpenCodeTeamRuntimeBridgePort,
|
OpenCodeTeamRuntimeBridgePort,
|
||||||
TeamLaunchRuntimeAdapter,
|
TeamLaunchRuntimeAdapter,
|
||||||
TeamRuntimeLaunchInput,
|
TeamRuntimeLaunchInput,
|
||||||
|
|
|
||||||
|
|
@ -15,8 +15,6 @@ export type OpenCodeBridgeCommandName =
|
||||||
| 'opencode.getRuntimeTranscript'
|
| 'opencode.getRuntimeTranscript'
|
||||||
| 'opencode.recoverDeliveryJournal';
|
| 'opencode.recoverDeliveryJournal';
|
||||||
|
|
||||||
export type OpenCodeTeamLaunchMode = 'disabled' | 'dogfood' | 'production';
|
|
||||||
|
|
||||||
export type OpenCodeTeamLaunchBridgeState =
|
export type OpenCodeTeamLaunchBridgeState =
|
||||||
| 'blocked'
|
| 'blocked'
|
||||||
| 'launching'
|
| 'launching'
|
||||||
|
|
@ -48,7 +46,6 @@ export interface OpenCodeTeamLaunchMemberCommandSpec {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeLaunchTeamCommandBody {
|
export interface OpenCodeLaunchTeamCommandBody {
|
||||||
mode: OpenCodeTeamLaunchMode;
|
|
||||||
runId: string;
|
runId: string;
|
||||||
laneId: string;
|
laneId: string;
|
||||||
teamId: 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 { OpenCodeTeamRuntimeBridgePort } from '../../runtime/OpenCodeTeamRuntimeAdapter';
|
||||||
import type {
|
import type {
|
||||||
OpenCodeTeamLaunchReadiness,
|
OpenCodeTeamLaunchReadiness,
|
||||||
|
|
@ -23,7 +16,6 @@ import type {
|
||||||
OpenCodeSendMessageCommandData,
|
OpenCodeSendMessageCommandData,
|
||||||
OpenCodeStopTeamCommandBody,
|
OpenCodeStopTeamCommandBody,
|
||||||
OpenCodeStopTeamCommandData,
|
OpenCodeStopTeamCommandData,
|
||||||
OpenCodeTeamLaunchMode,
|
|
||||||
} from './OpenCodeBridgeCommandContract';
|
} from './OpenCodeBridgeCommandContract';
|
||||||
import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService';
|
import type { OpenCodeStateChangingBridgeCommandService } from './OpenCodeStateChangingBridgeCommandService';
|
||||||
|
|
||||||
|
|
@ -48,29 +40,12 @@ export interface OpenCodeReadinessBridgeOptions {
|
||||||
sendTimeoutMs?: number;
|
sendTimeoutMs?: number;
|
||||||
stopTimeoutMs?: number;
|
stopTimeoutMs?: number;
|
||||||
stateChangingCommands?: Pick<OpenCodeStateChangingBridgeCommandService, 'execute'>;
|
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 {
|
export interface OpenCodeReadinessBridgeCommandBody {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
selectedModel: string | null;
|
selectedModel: string | null;
|
||||||
requireExecutionProbe: boolean;
|
requireExecutionProbe: boolean;
|
||||||
launchMode?: OpenCodeTeamLaunchMode;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
|
const DEFAULT_READINESS_TIMEOUT_MS = 120_000;
|
||||||
|
|
@ -103,11 +78,7 @@ export class OpenCodeReadinessBridge implements OpenCodeTeamRuntimeBridgePort {
|
||||||
|
|
||||||
if (result.ok) {
|
if (result.ok) {
|
||||||
this.lastRuntimeSnapshotsByProjectPath.set(input.projectPath, result.runtime);
|
this.lastRuntimeSnapshotsByProjectPath.set(input.projectPath, result.runtime);
|
||||||
return this.applyProductionE2EGate({
|
return result.data;
|
||||||
input,
|
|
||||||
readiness: result.data,
|
|
||||||
runtime: result.runtime,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
this.lastRuntimeSnapshotsByProjectPath.delete(input.projectPath);
|
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 {
|
getLastOpenCodeRuntimeSnapshot(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null {
|
||||||
return this.lastRuntimeSnapshotsByProjectPath.get(projectPath) ?? 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,
|
evaluateOpenCodeSupport,
|
||||||
OPENCODE_TEAM_LAUNCH_VERSION_POLICY,
|
OPENCODE_TEAM_LAUNCH_VERSION_POLICY,
|
||||||
type OpenCodeInstallMethod,
|
type OpenCodeInstallMethod,
|
||||||
type OpenCodeProductionE2EEvidence,
|
|
||||||
type OpenCodeSupportedVersionPolicy,
|
type OpenCodeSupportedVersionPolicy,
|
||||||
type OpenCodeSupportLevel,
|
type OpenCodeSupportLevel,
|
||||||
} from '../version/OpenCodeVersionPolicy';
|
} from '../version/OpenCodeVersionPolicy';
|
||||||
|
|
||||||
import type { OpenCodeTeamLaunchMode } from '../bridge/OpenCodeBridgeCommandContract';
|
|
||||||
import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities';
|
import type { OpenCodeApiCapabilities } from '../capabilities/OpenCodeApiCapabilities';
|
||||||
import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability';
|
import type { OpenCodeMcpToolProof } from '../mcp/OpenCodeMcpToolAvailability';
|
||||||
import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest';
|
import type { RuntimeStoreReadinessCheck } from '../store/RuntimeStoreManifest';
|
||||||
|
|
@ -18,7 +16,6 @@ export type OpenCodeTeamLaunchReadinessState =
|
||||||
| 'not_authenticated'
|
| 'not_authenticated'
|
||||||
| 'unsupported_version'
|
| 'unsupported_version'
|
||||||
| 'capabilities_missing'
|
| 'capabilities_missing'
|
||||||
| 'e2e_missing'
|
|
||||||
| 'runtime_store_blocked'
|
| 'runtime_store_blocked'
|
||||||
| 'mcp_unavailable'
|
| 'mcp_unavailable'
|
||||||
| 'model_unavailable'
|
| 'model_unavailable'
|
||||||
|
|
@ -98,21 +95,8 @@ export interface OpenCodeModelExecutionProbePort {
|
||||||
}): Promise<OpenCodeModelExecutionProbeResult>;
|
}): Promise<OpenCodeModelExecutionProbeResult>;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface OpenCodeProductionE2EEvidencePort {
|
|
||||||
read(input: {
|
|
||||||
projectPath: string;
|
|
||||||
inventory: OpenCodeRuntimeInventory;
|
|
||||||
capabilities: OpenCodeApiCapabilities;
|
|
||||||
}): Promise<OpenCodeProductionE2EEvidence | null>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface OpenCodeTeamLaunchReadinessServiceOptions {
|
export interface OpenCodeTeamLaunchReadinessServiceOptions {
|
||||||
versionPolicy?: OpenCodeSupportedVersionPolicy;
|
versionPolicy?: OpenCodeSupportedVersionPolicy;
|
||||||
launchMode?: OpenCodeTeamLaunchMode;
|
|
||||||
/**
|
|
||||||
* @deprecated Use launchMode. Kept for callers that still pass a boolean feature gate.
|
|
||||||
*/
|
|
||||||
adapterEnabled?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export class OpenCodeTeamLaunchReadinessService {
|
export class OpenCodeTeamLaunchReadinessService {
|
||||||
|
|
@ -122,7 +106,6 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
private readonly mcpTools: OpenCodeMcpToolProofPort,
|
private readonly mcpTools: OpenCodeMcpToolProofPort,
|
||||||
private readonly runtimeStores: OpenCodeRuntimeStoreReadinessPort,
|
private readonly runtimeStores: OpenCodeRuntimeStoreReadinessPort,
|
||||||
private readonly modelExecution: OpenCodeModelExecutionProbePort,
|
private readonly modelExecution: OpenCodeModelExecutionProbePort,
|
||||||
private readonly e2eEvidence: OpenCodeProductionE2EEvidencePort,
|
|
||||||
private readonly options: OpenCodeTeamLaunchReadinessServiceOptions = {}
|
private readonly options: OpenCodeTeamLaunchReadinessServiceOptions = {}
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
|
|
@ -130,21 +113,8 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
selectedModel: string | null;
|
selectedModel: string | null;
|
||||||
requireExecutionProbe: boolean;
|
requireExecutionProbe: boolean;
|
||||||
launchMode?: OpenCodeTeamLaunchMode;
|
|
||||||
}): Promise<OpenCodeTeamLaunchReadiness> {
|
}): Promise<OpenCodeTeamLaunchReadiness> {
|
||||||
const launchMode = resolveReadinessLaunchMode(input.launchMode, this.options);
|
|
||||||
const policy = this.options.versionPolicy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY;
|
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 {
|
try {
|
||||||
const inventory = await this.inventory.probe({ projectPath: input.projectPath });
|
const inventory = await this.inventory.probe({ projectPath: input.projectPath });
|
||||||
|
|
@ -184,24 +154,13 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
projectPath: input.projectPath,
|
projectPath: input.projectPath,
|
||||||
inventory,
|
inventory,
|
||||||
});
|
});
|
||||||
const evidence = await this.e2eEvidence.read({
|
|
||||||
projectPath: input.projectPath,
|
|
||||||
inventory,
|
|
||||||
capabilities,
|
|
||||||
});
|
|
||||||
const support = evaluateOpenCodeSupport({
|
const support = evaluateOpenCodeSupport({
|
||||||
version: inventory.version ?? '0.0.0',
|
version: inventory.version ?? '0.0.0',
|
||||||
capabilities,
|
capabilities,
|
||||||
evidence,
|
|
||||||
policy,
|
policy,
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!support.supported) {
|
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({
|
return readiness({
|
||||||
state: mapSupportLevelToReadinessState(support.supportLevel),
|
state: mapSupportLevelToReadinessState(support.supportLevel),
|
||||||
inventory,
|
inventory,
|
||||||
|
|
@ -212,7 +171,6 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics),
|
diagnostics: appendDiagnostics(inventory.diagnostics, support.diagnostics),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const runtimeStoreReadiness = await this.runtimeStores.check({
|
const runtimeStoreReadiness = await this.runtimeStores.check({
|
||||||
projectPath: input.projectPath,
|
projectPath: input.projectPath,
|
||||||
|
|
@ -280,7 +238,7 @@ export class OpenCodeTeamLaunchReadinessService {
|
||||||
runtimeStoreReadiness,
|
runtimeStoreReadiness,
|
||||||
supportLevel: support.supportLevel,
|
supportLevel: support.supportLevel,
|
||||||
launchAllowed: true,
|
launchAllowed: true,
|
||||||
diagnostics: appendDiagnostics(inventory.diagnostics, dogfoodWarnings),
|
diagnostics: inventory.diagnostics,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
return readiness({
|
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: {
|
function readiness(input: {
|
||||||
state: OpenCodeTeamLaunchReadinessState;
|
state: OpenCodeTeamLaunchReadinessState;
|
||||||
inventory: OpenCodeRuntimeInventory | null;
|
inventory: OpenCodeRuntimeInventory | null;
|
||||||
|
|
@ -361,8 +303,6 @@ function mapSupportLevelToReadinessState(
|
||||||
return 'unsupported_version';
|
return 'unsupported_version';
|
||||||
case 'supported_capabilities_pending':
|
case 'supported_capabilities_pending':
|
||||||
return 'capabilities_missing';
|
return 'capabilities_missing';
|
||||||
case 'supported_e2e_pending':
|
|
||||||
return 'e2e_missing';
|
|
||||||
case 'production_supported':
|
case 'production_supported':
|
||||||
return 'ready';
|
return 'ready';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,6 @@
|
||||||
import { createHash } from 'crypto';
|
import { createHash } from 'crypto';
|
||||||
import { promises as fs } from 'fs';
|
import { promises as fs } from 'fs';
|
||||||
|
|
||||||
import {
|
|
||||||
assertOpenCodeProductionE2EEvidenceBasics,
|
|
||||||
type OpenCodeProductionE2EEvidence,
|
|
||||||
} from '../e2e/OpenCodeProductionE2EEvidence';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
OpenCodeApiCapabilities,
|
OpenCodeApiCapabilities,
|
||||||
OpenCodeApiEndpointKey,
|
OpenCodeApiEndpointKey,
|
||||||
|
|
@ -14,18 +9,14 @@ import type {
|
||||||
|
|
||||||
export interface OpenCodeSupportedVersionPolicy {
|
export interface OpenCodeSupportedVersionPolicy {
|
||||||
minimumVersion: string;
|
minimumVersion: string;
|
||||||
testedVersion: string;
|
|
||||||
allowedPrerelease: boolean;
|
allowedPrerelease: boolean;
|
||||||
requireCapabilities: boolean;
|
requireCapabilities: boolean;
|
||||||
requireE2EArtifactsForTestedVersion: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const OPENCODE_TEAM_LAUNCH_VERSION_POLICY: OpenCodeSupportedVersionPolicy = {
|
export const OPENCODE_TEAM_LAUNCH_VERSION_POLICY: OpenCodeSupportedVersionPolicy = {
|
||||||
minimumVersion: '1.14.19',
|
minimumVersion: '1.14.19',
|
||||||
testedVersion: '1.14.19',
|
|
||||||
allowedPrerelease: false,
|
allowedPrerelease: false,
|
||||||
requireCapabilities: true,
|
requireCapabilities: true,
|
||||||
requireE2EArtifactsForTestedVersion: true,
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export type OpenCodeInstallMethod = 'brew' | 'npm' | 'bun' | 'manual' | 'unknown';
|
export type OpenCodeInstallMethod = 'brew' | 'npm' | 'bun' | 'manual' | 'unknown';
|
||||||
|
|
@ -41,11 +32,8 @@ export type OpenCodeSupportLevel =
|
||||||
| 'unsupported_too_old'
|
| 'unsupported_too_old'
|
||||||
| 'unsupported_prerelease'
|
| 'unsupported_prerelease'
|
||||||
| 'supported_capabilities_pending'
|
| 'supported_capabilities_pending'
|
||||||
| 'supported_e2e_pending'
|
|
||||||
| 'production_supported';
|
| 'production_supported';
|
||||||
|
|
||||||
export { type OpenCodeProductionE2EEvidence } from '../e2e/OpenCodeProductionE2EEvidence';
|
|
||||||
|
|
||||||
export interface OpenCodeCompatibilitySnapshot {
|
export interface OpenCodeCompatibilitySnapshot {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
|
|
@ -57,7 +45,6 @@ export interface OpenCodeCompatibilitySnapshot {
|
||||||
supported: boolean;
|
supported: boolean;
|
||||||
supportLevel: OpenCodeSupportLevel;
|
supportLevel: OpenCodeSupportLevel;
|
||||||
apiCapabilities: OpenCodeApiCapabilities;
|
apiCapabilities: OpenCodeApiCapabilities;
|
||||||
testedEvidencePath: string | null;
|
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -121,7 +108,6 @@ export function shouldReuseCompatibilitySnapshot(input: {
|
||||||
export function evaluateOpenCodeSupport(input: {
|
export function evaluateOpenCodeSupport(input: {
|
||||||
version: string;
|
version: string;
|
||||||
capabilities: OpenCodeApiCapabilities;
|
capabilities: OpenCodeApiCapabilities;
|
||||||
evidence: OpenCodeProductionE2EEvidence | null;
|
|
||||||
policy?: OpenCodeSupportedVersionPolicy;
|
policy?: OpenCodeSupportedVersionPolicy;
|
||||||
}): OpenCodeSupportDecision {
|
}): OpenCodeSupportDecision {
|
||||||
const policy = input.policy ?? OPENCODE_TEAM_LAUNCH_VERSION_POLICY;
|
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 {
|
return {
|
||||||
supported: true,
|
supported: true,
|
||||||
supportLevel: 'production_supported',
|
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(
|
export function selectPermissionReplyRouteFromCache(
|
||||||
cache: OpenCodeRouteCompatibilityCache
|
cache: OpenCodeRouteCompatibilityCache
|
||||||
): OpenCodePermissionReplyRoute | null {
|
): OpenCodePermissionReplyRoute | null {
|
||||||
|
|
|
||||||
|
|
@ -12,8 +12,14 @@
|
||||||
* diagnostics and completion-time reports.
|
* diagnostics and completion-time reports.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
|
import type { TeamLaunchDiagnosticItem } from '@shared/types';
|
||||||
|
|
||||||
export const PROGRESS_LOG_TAIL_LINES = 200;
|
export const PROGRESS_LOG_TAIL_LINES = 200;
|
||||||
export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
|
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"
|
* 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');
|
const joined = tail.join('\n\n');
|
||||||
return joined.trim().length === 0 ? undefined : joined;
|
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,
|
OpenCodeSendMessageCommandData,
|
||||||
OpenCodeStopTeamCommandBody,
|
OpenCodeStopTeamCommandBody,
|
||||||
OpenCodeStopTeamCommandData,
|
OpenCodeStopTeamCommandData,
|
||||||
OpenCodeTeamLaunchMode,
|
|
||||||
OpenCodeTeamMemberLaunchBridgeState,
|
OpenCodeTeamMemberLaunchBridgeState,
|
||||||
} from '../opencode/bridge/OpenCodeBridgeCommandContract';
|
} from '../opencode/bridge/OpenCodeBridgeCommandContract';
|
||||||
import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness';
|
import type { OpenCodeTeamLaunchReadiness } from '../opencode/readiness/OpenCodeTeamLaunchReadiness';
|
||||||
|
|
@ -33,7 +32,6 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
||||||
projectPath: string;
|
projectPath: string;
|
||||||
selectedModel: string | null;
|
selectedModel: string | null;
|
||||||
requireExecutionProbe: boolean;
|
requireExecutionProbe: boolean;
|
||||||
launchMode?: OpenCodeTeamLaunchMode;
|
|
||||||
}): Promise<OpenCodeTeamLaunchReadiness>;
|
}): Promise<OpenCodeTeamLaunchReadiness>;
|
||||||
getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null;
|
getLastOpenCodeRuntimeSnapshot?(projectPath: string): OpenCodeBridgeRuntimeSnapshot | null;
|
||||||
launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise<OpenCodeLaunchTeamCommandData>;
|
launchOpenCodeTeam?(input: OpenCodeLaunchTeamCommandBody): Promise<OpenCodeLaunchTeamCommandData>;
|
||||||
|
|
@ -46,14 +44,6 @@ export interface OpenCodeTeamRuntimeBridgePort {
|
||||||
): Promise<OpenCodeSendMessageCommandData>;
|
): 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 {
|
export interface OpenCodeTeamRuntimeMessageInput {
|
||||||
runId?: string;
|
runId?: string;
|
||||||
teamName: string;
|
teamName: string;
|
||||||
|
|
@ -76,8 +66,6 @@ export interface OpenCodeTeamRuntimeMessageResult {
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
export { type OpenCodeTeamLaunchMode } from '../opencode/bridge/OpenCodeBridgeCommandContract';
|
|
||||||
|
|
||||||
const REQUIRED_READY_CHECKPOINTS = new Set([
|
const REQUIRED_READY_CHECKPOINTS = new Set([
|
||||||
'required_tools_proven',
|
'required_tools_proven',
|
||||||
'delivery_ready',
|
'delivery_ready',
|
||||||
|
|
@ -90,32 +78,14 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
private readonly lastProjectPathByTeamName = new Map<string, string>();
|
private readonly lastProjectPathByTeamName = new Map<string, string>();
|
||||||
private readonly lastReadinessByProjectPath = new Map<string, OpenCodeTeamLaunchReadiness>();
|
private readonly lastReadinessByProjectPath = new Map<string, OpenCodeTeamLaunchReadiness>();
|
||||||
|
|
||||||
constructor(
|
constructor(private readonly bridge: OpenCodeTeamRuntimeBridgePort) {}
|
||||||
private readonly bridge: OpenCodeTeamRuntimeBridgePort,
|
|
||||||
private readonly options: OpenCodeTeamRuntimeAdapterOptions = {}
|
|
||||||
) {}
|
|
||||||
|
|
||||||
async prepare(input: TeamRuntimeLaunchInput): Promise<TeamRuntimePrepareResult> {
|
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 runtimeOnly = input.runtimeOnly === true;
|
||||||
const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({
|
const readiness = await this.bridge.checkOpenCodeTeamLaunchReadiness({
|
||||||
projectPath: input.cwd,
|
projectPath: input.cwd,
|
||||||
selectedModel: input.model ?? null,
|
selectedModel: input.model ?? null,
|
||||||
requireExecutionProbe: !runtimeOnly,
|
requireExecutionProbe: !runtimeOnly,
|
||||||
launchMode: runtimeOnly ? undefined : configuredLaunchMode,
|
|
||||||
});
|
});
|
||||||
this.lastReadinessByProjectPath.set(input.cwd, readiness);
|
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 {
|
return {
|
||||||
ok: true,
|
ok: true,
|
||||||
providerId: this.providerId,
|
providerId: this.providerId,
|
||||||
modelId: readiness.modelId,
|
modelId: readiness.modelId,
|
||||||
diagnostics: readiness.diagnostics,
|
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);
|
const prepared = await this.prepare(input);
|
||||||
if (!prepared.ok) {
|
if (!prepared.ok) {
|
||||||
return blockedLaunchResult(input, prepared.reason, prepared.diagnostics, prepared.warnings);
|
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;
|
const runtimeSnapshot = this.bridge.getLastOpenCodeRuntimeSnapshot?.(input.cwd) ?? null;
|
||||||
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
|
this.lastProjectPathByTeamName.set(input.teamName, input.cwd);
|
||||||
const data = await this.bridge.launchOpenCodeTeam({
|
const data = await this.bridge.launchOpenCodeTeam({
|
||||||
mode: configuredLaunchMode,
|
|
||||||
runId: input.runId,
|
runId: input.runId,
|
||||||
laneId: input.laneId?.trim() || 'primary',
|
laneId: input.laneId?.trim() || 'primary',
|
||||||
teamId: input.teamName,
|
teamId: input.teamName,
|
||||||
|
|
@ -307,7 +251,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
providerId: this.providerId,
|
providerId: this.providerId,
|
||||||
launchState: member.launchState,
|
launchState: member.launchState,
|
||||||
agentToolAccepted: member.agentToolAccepted,
|
agentToolAccepted: member.agentToolAccepted,
|
||||||
runtimeAlive: member.runtimeAlive,
|
runtimeAlive: member.bootstrapConfirmed === true,
|
||||||
bootstrapConfirmed: member.bootstrapConfirmed,
|
bootstrapConfirmed: member.bootstrapConfirmed,
|
||||||
hardFailure: member.hardFailure,
|
hardFailure: member.hardFailure,
|
||||||
hardFailureReason: member.hardFailureReason,
|
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(
|
function mapOpenCodeLaunchDataToRuntimeResult(
|
||||||
input: TeamRuntimeLaunchInput,
|
input: TeamRuntimeLaunchInput,
|
||||||
data: OpenCodeLaunchTeamCommandData,
|
data: OpenCodeLaunchTeamCommandData,
|
||||||
|
|
@ -549,9 +481,26 @@ function mapBridgeMemberToRuntimeEvidence(
|
||||||
diagnostics: string[]
|
diagnostics: string[]
|
||||||
): TeamRuntimeMemberLaunchEvidence {
|
): TeamRuntimeMemberLaunchEvidence {
|
||||||
const confirmed = launchState === 'confirmed_alive';
|
const confirmed = launchState === 'confirmed_alive';
|
||||||
const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked';
|
|
||||||
const failed = launchState === 'failed';
|
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 {
|
return {
|
||||||
memberName,
|
memberName,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
@ -562,8 +511,13 @@ function mapBridgeMemberToRuntimeEvidence(
|
||||||
: launchState === 'permission_blocked'
|
: launchState === 'permission_blocked'
|
||||||
? 'runtime_pending_permission'
|
? 'runtime_pending_permission'
|
||||||
: 'runtime_pending_bootstrap',
|
: 'runtime_pending_bootstrap',
|
||||||
agentToolAccepted: confirmed || pendingRuntimeObserved,
|
agentToolAccepted:
|
||||||
runtimeAlive: confirmed || pendingRuntimeObserved,
|
confirmed ||
|
||||||
|
pendingRuntimeObserved ||
|
||||||
|
launchState === 'permission_blocked' ||
|
||||||
|
runtimeMaterialized ||
|
||||||
|
Boolean(sessionId),
|
||||||
|
runtimeAlive: confirmed,
|
||||||
bootstrapConfirmed: confirmed,
|
bootstrapConfirmed: confirmed,
|
||||||
hardFailure: failed,
|
hardFailure: failed,
|
||||||
hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined,
|
hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined,
|
||||||
|
|
@ -572,9 +526,10 @@ function mapBridgeMemberToRuntimeEvidence(
|
||||||
? [...new Set(pendingPermissionRequestIds)]
|
? [...new Set(pendingPermissionRequestIds)]
|
||||||
: undefined,
|
: undefined,
|
||||||
sessionId,
|
sessionId,
|
||||||
...(typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0
|
...(hasRuntimePid ? { runtimePid } : {}),
|
||||||
? { runtimePid }
|
livenessKind,
|
||||||
: {}),
|
...(hasRuntimePid ? { pidSource: 'opencode_bridge' as const } : {}),
|
||||||
|
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||||
diagnostics,
|
diagnostics,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -709,7 +664,6 @@ function isRetryableReadinessState(state: OpenCodeTeamLaunchReadiness['state']):
|
||||||
return (
|
return (
|
||||||
state === 'not_installed' ||
|
state === 'not_installed' ||
|
||||||
state === 'not_authenticated' ||
|
state === 'not_authenticated' ||
|
||||||
state === 'e2e_missing' ||
|
|
||||||
state === 'runtime_store_blocked' ||
|
state === 'runtime_store_blocked' ||
|
||||||
state === 'mcp_unavailable' ||
|
state === 'mcp_unavailable' ||
|
||||||
state === 'model_unavailable' ||
|
state === 'model_unavailable' ||
|
||||||
|
|
|
||||||
|
|
@ -4,6 +4,8 @@ import type {
|
||||||
PersistedTeamLaunchPhase,
|
PersistedTeamLaunchPhase,
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
TeamAgentRuntimeBackendType,
|
TeamAgentRuntimeBackendType,
|
||||||
|
TeamAgentRuntimeLivenessKind,
|
||||||
|
TeamAgentRuntimePidSource,
|
||||||
TeamLaunchAggregateState,
|
TeamLaunchAggregateState,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -73,6 +75,9 @@ export interface TeamRuntimeMemberLaunchEvidence {
|
||||||
sessionId?: string;
|
sessionId?: string;
|
||||||
backendType?: TeamAgentRuntimeBackendType;
|
backendType?: TeamAgentRuntimeBackendType;
|
||||||
runtimePid?: number;
|
runtimePid?: number;
|
||||||
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
|
pidSource?: TeamAgentRuntimePidSource;
|
||||||
|
runtimeDiagnostic?: string;
|
||||||
diagnostics: string[];
|
diagnostics: string[];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
export type {
|
export type {
|
||||||
OpenCodeTeamLaunchMode,
|
|
||||||
OpenCodeTeamRuntimeAdapterOptions,
|
|
||||||
OpenCodeTeamRuntimeBridgePort,
|
OpenCodeTeamRuntimeBridgePort,
|
||||||
OpenCodeTeamRuntimeMessageInput,
|
OpenCodeTeamRuntimeMessageInput,
|
||||||
OpenCodeTeamRuntimeMessageResult,
|
OpenCodeTeamRuntimeMessageResult,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ export function encodePath(absolutePath: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoded = absolutePath.replace(/[/\\]/g, '-');
|
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
|
// Ensure leading dash for absolute paths
|
||||||
return encoded.startsWith('-') ? encoded : `-${encoded}`;
|
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"
|
// Legacy Windows format observed in some Claude installs: "C--Users-name-project"
|
||||||
// (no leading dash, drive separator encoded as "--").
|
// (no leading dash, drive separator encoded as "--").
|
||||||
const legacyWindowsRegex = /^([a-zA-Z])--(.+)$/;
|
const legacyWindowsRegex = /^([a-zA-Z])--(.*)$/;
|
||||||
const legacyWindowsMatch = legacyWindowsRegex.exec(encodedName);
|
const legacyWindowsMatch = legacyWindowsRegex.exec(encodedName);
|
||||||
if (legacyWindowsMatch) {
|
if (legacyWindowsMatch) {
|
||||||
const drive = legacyWindowsMatch[1].toUpperCase();
|
const drive = legacyWindowsMatch[1].toUpperCase();
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,13 @@ interface TaskReadDiag {
|
||||||
|
|
||||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||||
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
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)
|
// Parsed JSON types (loose shapes from disk)
|
||||||
|
|
@ -743,26 +750,64 @@ function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefin
|
||||||
.map((i) => ({ ...i }));
|
.map((i) => ({ ...i }));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function normalizeReviewState(value: unknown): string {
|
||||||
|
return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none';
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFallbackReviewState(value: unknown, status: string): string {
|
||||||
|
const reviewState = normalizeReviewState(value);
|
||||||
|
if (reviewState === 'none') return 'none';
|
||||||
|
if (status === 'in_progress' || status === 'deleted') return 'none';
|
||||||
|
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
||||||
|
if (status === 'completed') {
|
||||||
|
return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none';
|
||||||
|
}
|
||||||
|
return reviewState;
|
||||||
|
}
|
||||||
|
|
||||||
|
function eventReviewState(event: RawHistoryEvent): string | null {
|
||||||
|
const type = typeof event.type === 'string' ? event.type : '';
|
||||||
|
if (!REVIEW_LIFECYCLE_EVENTS.has(type)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return normalizeReviewState(event.to);
|
||||||
|
}
|
||||||
|
|
||||||
|
function derivePendingReviewState(events: RawHistoryEvent[], startIndex: number): string {
|
||||||
|
for (let i = startIndex - 1; i >= 0; i--) {
|
||||||
|
const previous = events[i];
|
||||||
|
const reviewState = eventReviewState(previous);
|
||||||
|
if (reviewState) {
|
||||||
|
return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
previous.type === 'task_created' ||
|
||||||
|
(previous.type === 'status_changed' &&
|
||||||
|
(REVIEW_RESET_STATUSES.has(String(previous.to || '')) || previous.to === 'pending'))
|
||||||
|
) {
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return 'none';
|
||||||
|
}
|
||||||
|
|
||||||
/** Derive review state from historyEvents (inline reducer for worker isolation). */
|
/** Derive review state from historyEvents (inline reducer for worker isolation). */
|
||||||
function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string {
|
function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string | null {
|
||||||
if (!Array.isArray(events) || events.length === 0) return 'none';
|
if (!Array.isArray(events) || events.length === 0) return null;
|
||||||
for (let i = events.length - 1; i >= 0; i--) {
|
for (let i = events.length - 1; i >= 0; i--) {
|
||||||
const e = events[i];
|
const e = events[i];
|
||||||
const t = e.type;
|
const reviewState = eventReviewState(e);
|
||||||
if (
|
if (reviewState) {
|
||||||
t === 'review_requested' ||
|
return reviewState;
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
if (t === 'status_changed' && e.to === 'in_progress') {
|
if (e.type === 'status_changed' && REVIEW_RESET_STATUSES.has(String(e.to || ''))) {
|
||||||
return 'none';
|
return 'none';
|
||||||
}
|
}
|
||||||
|
if (e.type === 'status_changed' && e.to === 'pending') {
|
||||||
|
return derivePendingReviewState(events, i);
|
||||||
}
|
}
|
||||||
return 'none';
|
}
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
||||||
|
|
@ -869,7 +914,16 @@ async function readTasksDirForTeam(
|
||||||
? (parsed.needsClarification as string)
|
? (parsed.needsClarification as string)
|
||||||
: undefined;
|
: undefined;
|
||||||
const historyEvents = normalizeHistoryEvents(parsed);
|
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({
|
tasks.push({
|
||||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||||
|
|
@ -896,13 +950,7 @@ async function readTasksDirForTeam(
|
||||||
: undefined,
|
: undefined,
|
||||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||||
status:
|
status,
|
||||||
parsed.status === 'pending' ||
|
|
||||||
parsed.status === 'in_progress' ||
|
|
||||||
parsed.status === 'completed' ||
|
|
||||||
parsed.status === 'deleted'
|
|
||||||
? (parsed.status as string)
|
|
||||||
: 'pending',
|
|
||||||
workIntervals: normalizeWorkIntervals(parsed),
|
workIntervals: normalizeWorkIntervals(parsed),
|
||||||
historyEvents: normalizeHistoryEvents(parsed),
|
historyEvents: normalizeHistoryEvents(parsed),
|
||||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
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 */
|
/** Restart a specific teammate runtime */
|
||||||
export const TEAM_RESTART_MEMBER = 'team:restartMember';
|
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) */
|
/** Soft-delete a task (set status to 'deleted' with deletedAt timestamp) */
|
||||||
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
export const TEAM_SOFT_DELETE_TASK = 'team:softDeleteTask';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -171,6 +171,7 @@ import {
|
||||||
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
TEAM_SET_TASK_LOG_STREAM_TRACKING,
|
||||||
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
TEAM_SET_TOOL_ACTIVITY_TRACKING,
|
||||||
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
TEAM_SHOW_MESSAGE_NOTIFICATION,
|
||||||
|
TEAM_SKIP_MEMBER_FOR_LAUNCH,
|
||||||
TEAM_SOFT_DELETE_TASK,
|
TEAM_SOFT_DELETE_TASK,
|
||||||
TEAM_START_TASK,
|
TEAM_START_TASK,
|
||||||
TEAM_START_TASK_BY_USER,
|
TEAM_START_TASK_BY_USER,
|
||||||
|
|
@ -1093,6 +1094,9 @@ const electronAPI: ElectronAPI = {
|
||||||
restartMember: async (teamName: string, memberName: string) => {
|
restartMember: async (teamName: string, memberName: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_RESTART_MEMBER, teamName, memberName);
|
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) => {
|
softDeleteTask: async (teamName: string, taskId: string) => {
|
||||||
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
return invokeIpcWithResult<void>(TEAM_SOFT_DELETE_TASK, teamName, taskId);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -959,6 +959,9 @@ export class HttpAPIClient implements ElectronAPI {
|
||||||
restartMember: async (): Promise<void> => {
|
restartMember: async (): Promise<void> => {
|
||||||
throw new Error('Member restart is not available in browser mode');
|
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> => {
|
softDeleteTask: async (_teamName: string, _taskId: string): Promise<void> => {
|
||||||
// Not available via HTTP client — no-op
|
// Not available via HTTP client — no-op
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import {
|
||||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
|
||||||
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
||||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||||
|
|
@ -265,11 +264,8 @@ interface InstalledBannerProps {
|
||||||
cliStatusError: string | null;
|
cliStatusError: string | null;
|
||||||
providersCollapsed: boolean;
|
providersCollapsed: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
multimodelEnabled: boolean;
|
|
||||||
multimodelBusy: boolean;
|
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onMultimodelToggle: (enabled: boolean) => void;
|
|
||||||
onToggleProvidersCollapsed: () => void;
|
onToggleProvidersCollapsed: () => void;
|
||||||
onProviderLogin: (providerId: CliProviderId) => void;
|
onProviderLogin: (providerId: CliProviderId) => void;
|
||||||
onProviderLogout: (providerId: CliProviderId) => void;
|
onProviderLogout: (providerId: CliProviderId) => void;
|
||||||
|
|
@ -573,11 +569,8 @@ const InstalledBanner = ({
|
||||||
cliStatusError,
|
cliStatusError,
|
||||||
providersCollapsed,
|
providersCollapsed,
|
||||||
isBusy,
|
isBusy,
|
||||||
multimodelEnabled,
|
|
||||||
multimodelBusy,
|
|
||||||
onInstall,
|
onInstall,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onMultimodelToggle,
|
|
||||||
onToggleProvidersCollapsed,
|
onToggleProvidersCollapsed,
|
||||||
onProviderLogin,
|
onProviderLogin,
|
||||||
onProviderLogout,
|
onProviderLogout,
|
||||||
|
|
@ -683,23 +676,6 @@ const InstalledBanner = ({
|
||||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
Multimodel
|
Multimodel
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{/* Extensions button — available whenever the runtime is installed */}
|
{/* Extensions button — available whenever the runtime is installed */}
|
||||||
{canOpenExtensions && (
|
{canOpenExtensions && (
|
||||||
|
|
@ -1033,7 +1009,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('anthropic');
|
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('anthropic');
|
||||||
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||||
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
||||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
|
||||||
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
||||||
const [providersCollapsed, setProvidersCollapsed] = useState(() =>
|
const [providersCollapsed, setProvidersCollapsed] = useState(() =>
|
||||||
loadDashboardCliStatusBannerCollapsed()
|
loadDashboardCliStatusBannerCollapsed()
|
||||||
|
|
@ -1147,37 +1122,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
})();
|
})();
|
||||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
}, [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(() => {
|
const recheckAuthState = useCallback(() => {
|
||||||
setIsVerifyingAuth(true);
|
setIsVerifyingAuth(true);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -1422,11 +1366,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
cliStatusError={cliStatusError ?? null}
|
cliStatusError={cliStatusError ?? null}
|
||||||
providersCollapsed={providersCollapsed}
|
providersCollapsed={providersCollapsed}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
multimodelEnabled={multimodelEnabled}
|
|
||||||
multimodelBusy={isSwitchingFlavor}
|
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
onProviderLogout={handleProviderLogout}
|
onProviderLogout={handleProviderLogout}
|
||||||
|
|
@ -1650,11 +1591,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
cliStatusError={cliStatusError ?? null}
|
cliStatusError={cliStatusError ?? null}
|
||||||
providersCollapsed={providersCollapsed}
|
providersCollapsed={providersCollapsed}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
multimodelEnabled={multimodelEnabled}
|
|
||||||
multimodelBusy={isSwitchingFlavor}
|
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
onProviderLogout={handleProviderLogout}
|
onProviderLogout={handleProviderLogout}
|
||||||
|
|
@ -1712,11 +1650,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
cliStatusError={cliStatusError ?? null}
|
cliStatusError={cliStatusError ?? null}
|
||||||
providersCollapsed={providersCollapsed}
|
providersCollapsed={providersCollapsed}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
multimodelEnabled={multimodelEnabled}
|
|
||||||
multimodelBusy={isSwitchingFlavor}
|
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
onProviderLogout={handleProviderLogout}
|
onProviderLogout={handleProviderLogout}
|
||||||
|
|
@ -1934,11 +1869,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
cliStatusError={cliStatusError ?? null}
|
cliStatusError={cliStatusError ?? null}
|
||||||
providersCollapsed={providersCollapsed}
|
providersCollapsed={providersCollapsed}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
multimodelEnabled={multimodelEnabled}
|
|
||||||
multimodelBusy={isSwitchingFlavor}
|
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
onProviderLogout={handleProviderLogout}
|
onProviderLogout={handleProviderLogout}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import {
|
||||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
|
||||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
@ -211,7 +210,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
|
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
|
||||||
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
|
||||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||||
const loadingCliStatus =
|
const loadingCliStatus =
|
||||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||||
|
|
@ -323,37 +321,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
})();
|
})();
|
||||||
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
|
}, [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(
|
const handleRuntimeBackendChange = useCallback(
|
||||||
async (providerId: CliProviderId, backendId: string) => {
|
async (providerId: CliProviderId, backendId: string) => {
|
||||||
const currentBackends = appConfig?.runtime?.providerBackends ?? {
|
const currentBackends = appConfig?.runtime?.providerBackends ?? {
|
||||||
|
|
@ -440,23 +407,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
>
|
>
|
||||||
Multimodel
|
Multimodel
|
||||||
</span>
|
</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>
|
</div>
|
||||||
{/* Inline action buttons */}
|
{/* Inline action buttons */}
|
||||||
{effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (
|
{effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ import { DISPLAY_STEPS } from './provisioningSteps';
|
||||||
import { StepProgressBar } from './StepProgressBar';
|
import { StepProgressBar } from './StepProgressBar';
|
||||||
|
|
||||||
import type { StepProgressBarStep } from './StepProgressBar';
|
import type { StepProgressBarStep } from './StepProgressBar';
|
||||||
|
import type { TeamLaunchDiagnosticItem } from '@shared/types';
|
||||||
|
|
||||||
/** Pre-built step definitions for the provisioning stepper. */
|
/** Pre-built step definitions for the provisioning stepper. */
|
||||||
const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
|
const PROVISIONING_STEPS: StepProgressBarStep[] = DISPLAY_STEPS.map((s) => ({
|
||||||
|
|
@ -61,6 +62,8 @@ export interface ProvisioningProgressBlockProps {
|
||||||
cliLogsTail?: string;
|
cliLogsTail?: string;
|
||||||
/** Accumulated assistant text output for live preview */
|
/** Accumulated assistant text output for live preview */
|
||||||
assistantOutput?: string;
|
assistantOutput?: string;
|
||||||
|
/** Bounded structured launch diagnostics */
|
||||||
|
launchDiagnostics?: TeamLaunchDiagnosticItem[];
|
||||||
/** Visual surface chrome for the outer block */
|
/** Visual surface chrome for the outer block */
|
||||||
surface?: 'raised' | 'flat';
|
surface?: 'raised' | 'flat';
|
||||||
className?: string;
|
className?: string;
|
||||||
|
|
@ -153,15 +156,20 @@ export const ProvisioningProgressBlock = ({
|
||||||
pid,
|
pid,
|
||||||
cliLogsTail,
|
cliLogsTail,
|
||||||
assistantOutput,
|
assistantOutput,
|
||||||
|
launchDiagnostics,
|
||||||
surface = 'raised',
|
surface = 'raised',
|
||||||
className,
|
className,
|
||||||
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
}: ProvisioningProgressBlockProps): React.JSX.Element => {
|
||||||
const elapsed = useElapsedTimer(startedAt, loading);
|
const elapsed = useElapsedTimer(startedAt, loading);
|
||||||
const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false);
|
const [logsOpen, setLogsOpen] = useState(() => defaultLogsOpen ?? false);
|
||||||
|
const [diagnosticsOpen, setDiagnosticsOpen] = useState(false);
|
||||||
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
|
const [liveOutputOpen, setLiveOutputOpen] = useState(defaultLiveOutputOpen);
|
||||||
const outputScrollRef = useRef<HTMLDivElement>(null);
|
const outputScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const isError = tone === 'error';
|
const isError = tone === 'error';
|
||||||
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
|
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
|
||||||
|
const visibleLaunchDiagnostics =
|
||||||
|
launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ??
|
||||||
|
[];
|
||||||
|
|
||||||
// Auto-scroll assistant output
|
// Auto-scroll assistant output
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -293,6 +301,42 @@ export const ProvisioningProgressBlock = ({
|
||||||
errorIndex={errorStepIndex}
|
errorIndex={errorStepIndex}
|
||||||
/>
|
/>
|
||||||
</div>
|
</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">
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,6 @@ import { buildPendingRuntimeSummaryCopy } from '@renderer/utils/teamLaunchSummar
|
||||||
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
import { stripAgentBlocks } from '@shared/constants/agentBlocks';
|
||||||
import { deriveContextMetrics } from '@shared/utils/contextMetrics';
|
import { deriveContextMetrics } from '@shared/utils/contextMetrics';
|
||||||
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadAgentType, isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
|
||||||
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId, formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import {
|
import {
|
||||||
AlertTriangle,
|
AlertTriangle,
|
||||||
|
|
@ -158,11 +157,6 @@ interface CreateTaskDialogState {
|
||||||
defaultChip?: InlineChip;
|
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;
|
const TEAM_PENDING_REPLY_REFRESH_DELAY_MS = 10_000;
|
||||||
|
|
||||||
function areResolvedMembersEqual(
|
function areResolvedMembersEqual(
|
||||||
|
|
@ -257,7 +251,7 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
|
||||||
memberCount: team.memberCount,
|
memberCount: team.memberCount,
|
||||||
expectedMemberCount: team.expectedMemberCount,
|
expectedMemberCount: team.expectedMemberCount,
|
||||||
confirmedCount: team.confirmedCount,
|
confirmedCount: team.confirmedCount,
|
||||||
runtimeAlivePendingCount: team.runtimeAlivePendingCount,
|
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
|
||||||
teamLaunchState: team.teamLaunchState,
|
teamLaunchState: team.teamLaunchState,
|
||||||
partialLaunchFailure: team.partialLaunchFailure,
|
partialLaunchFailure: team.partialLaunchFailure,
|
||||||
missingMemberCount: team.missingMembers?.length ?? 0,
|
missingMemberCount: team.missingMembers?.length ?? 0,
|
||||||
|
|
@ -267,12 +261,12 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
summary?.teamLaunchState === 'partial_pending'
|
summary?.teamLaunchState === 'partial_pending'
|
||||||
? summary.runtimeAlivePendingCount != null && summary.runtimeAlivePendingCount > 0
|
? summary.runtimeProcessPendingCount != null && summary.runtimeProcessPendingCount > 0
|
||||||
? buildPendingRuntimeSummaryCopy({
|
? buildPendingRuntimeSummaryCopy({
|
||||||
confirmedCount: summary.confirmedCount,
|
confirmedCount: summary.confirmedCount,
|
||||||
expectedMemberCount: summary.expectedMemberCount,
|
expectedMemberCount: summary.expectedMemberCount,
|
||||||
memberCount: summary.memberCount,
|
memberCount: summary.memberCount,
|
||||||
runtimeAlivePendingCount: summary.runtimeAlivePendingCount,
|
runtimeProcessPendingCount: summary.runtimeProcessPendingCount,
|
||||||
})
|
})
|
||||||
: 'Last launch is still reconciling'
|
: 'Last launch is still reconciling'
|
||||||
: summary?.partialLaunchFailure
|
: summary?.partialLaunchFailure
|
||||||
|
|
@ -809,6 +803,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
||||||
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
|
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
|
||||||
[runtimeSnapshot?.members]
|
[runtimeSnapshot?.members]
|
||||||
);
|
);
|
||||||
|
const runtimeRunId = runtimeSnapshot?.runId ?? memberSpawnSnapshot?.runId ?? progress?.runId;
|
||||||
const isLaunchSettling = useMemo(() => {
|
const isLaunchSettling = useMemo(() => {
|
||||||
if (progress?.state !== 'ready') {
|
if (progress?.state !== 'ready') {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -828,6 +823,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
memberSpawnStatuses={memberSpawnStatusMap}
|
memberSpawnStatuses={memberSpawnStatusMap}
|
||||||
memberRuntimeEntries={memberRuntimeMap}
|
memberRuntimeEntries={memberRuntimeMap}
|
||||||
|
runtimeRunId={runtimeRunId}
|
||||||
isLaunchSettling={isLaunchSettling}
|
isLaunchSettling={isLaunchSettling}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -889,6 +885,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
|
runtimeRunId,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
|
|
@ -899,6 +896,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||||
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
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,
|
runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
@ -924,6 +925,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
spawnEntry={spawnEntry}
|
spawnEntry={spawnEntry}
|
||||||
runtimeEntry={runtimeEntry}
|
runtimeEntry={runtimeEntry}
|
||||||
|
runtimeRunId={runtimeRunId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
@ -932,13 +934,6 @@ export const TeamDetailView = ({
|
||||||
teamName,
|
teamName,
|
||||||
isPaneFocused = false,
|
isPaneFocused = false,
|
||||||
}: TeamDetailViewProps): React.JSX.Element => {
|
}: 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 { isLight } = useTheme();
|
||||||
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
const [requestChangesTaskId, setRequestChangesTaskId] = useState<string | null>(null);
|
||||||
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
const [selectedTask, setSelectedTask] = useState<TeamTaskWithKanban | null>(null);
|
||||||
|
|
@ -1240,6 +1235,7 @@ export const TeamDetailView = ({
|
||||||
reviewActionError,
|
reviewActionError,
|
||||||
addMember,
|
addMember,
|
||||||
restartMember,
|
restartMember,
|
||||||
|
skipMemberForLaunch,
|
||||||
removeMember,
|
removeMember,
|
||||||
updateMemberRole,
|
updateMemberRole,
|
||||||
launchTeam,
|
launchTeam,
|
||||||
|
|
@ -1290,6 +1286,7 @@ export const TeamDetailView = ({
|
||||||
reviewActionError: s.reviewActionError,
|
reviewActionError: s.reviewActionError,
|
||||||
addMember: s.addMember,
|
addMember: s.addMember,
|
||||||
restartMember: s.restartMember,
|
restartMember: s.restartMember,
|
||||||
|
skipMemberForLaunch: s.skipMemberForLaunch,
|
||||||
removeMember: s.removeMember,
|
removeMember: s.removeMember,
|
||||||
updateMemberRole: s.updateMemberRole,
|
updateMemberRole: s.updateMemberRole,
|
||||||
launchTeam: s.launchTeam,
|
launchTeam: s.launchTeam,
|
||||||
|
|
@ -1328,38 +1325,6 @@ export const TeamDetailView = ({
|
||||||
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
const isThisTabActive = tabId ? activeTabId === tabId : false;
|
||||||
const wasInteractiveRef = useRef(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
|
// Messages panel resize
|
||||||
const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } =
|
const { isResizing: isMessagesPanelResizing, handleProps: messagesPanelHandleProps } =
|
||||||
useResizablePanel({
|
useResizablePanel({
|
||||||
|
|
@ -1795,6 +1760,20 @@ export const TeamDetailView = ({
|
||||||
openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch');
|
openLaunchDialog(data?.isAlive && !isTeamProvisioning ? 'relaunch' : 'launch');
|
||||||
}, [data?.isAlive, isTeamProvisioning, openLaunchDialog]);
|
}, [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) => {
|
const handleSelectMember = useCallback((member: ResolvedTeamMember) => {
|
||||||
setSelectedMember(member);
|
setSelectedMember(member);
|
||||||
setSelectedMemberView(null);
|
setSelectedMemberView(null);
|
||||||
|
|
@ -2484,6 +2463,8 @@ export const TeamDetailView = ({
|
||||||
onSendMessage={handleSendMessageToMember}
|
onSendMessage={handleSendMessageToMember}
|
||||||
onAssignTask={handleAssignTaskToMember}
|
onAssignTask={handleAssignTaskToMember}
|
||||||
onOpenTask={handleOpenTaskById}
|
onOpenTask={handleOpenTaskById}
|
||||||
|
onRestartMember={handleRestartMember}
|
||||||
|
onSkipMemberForLaunch={handleSkipMemberForLaunch}
|
||||||
/>
|
/>
|
||||||
</CollapsibleTeamSection>
|
</CollapsibleTeamSection>
|
||||||
|
|
||||||
|
|
@ -2783,7 +2764,7 @@ export const TeamDetailView = ({
|
||||||
closeSelectedMemberDialog();
|
closeSelectedMemberDialog();
|
||||||
openCreateTaskDialog('', '', name);
|
openCreateTaskDialog('', '', name);
|
||||||
}}
|
}}
|
||||||
onRestartMember={(memberName) => restartMember(teamName, memberName)}
|
onRestartMember={handleRestartMember}
|
||||||
onTaskClick={(task) => {
|
onTaskClick={(task) => {
|
||||||
closeSelectedMemberDialog();
|
closeSelectedMemberDialog();
|
||||||
setSelectedTask(task);
|
setSelectedTask(task);
|
||||||
|
|
|
||||||
|
|
@ -82,6 +82,7 @@ type TeamStatus =
|
||||||
| 'provisioning'
|
| 'provisioning'
|
||||||
| 'offline'
|
| 'offline'
|
||||||
| 'partial_failure'
|
| 'partial_failure'
|
||||||
|
| 'partial_skipped'
|
||||||
| 'partial_pending';
|
| 'partial_pending';
|
||||||
|
|
||||||
function getRecentProjects(team: TeamSummary): string[] {
|
function getRecentProjects(team: TeamSummary): string[] {
|
||||||
|
|
@ -206,6 +207,9 @@ function resolveTeamStatus(
|
||||||
if (team.teamLaunchState === 'partial_pending') {
|
if (team.teamLaunchState === 'partial_pending') {
|
||||||
return 'partial_pending';
|
return 'partial_pending';
|
||||||
}
|
}
|
||||||
|
if (team.teamLaunchState === 'partial_skipped') {
|
||||||
|
return 'partial_skipped';
|
||||||
|
}
|
||||||
if (team.partialLaunchFailure || team.teamLaunchState === 'partial_failure') {
|
if (team.partialLaunchFailure || team.teamLaunchState === 'partial_failure') {
|
||||||
return 'partial_failure';
|
return 'partial_failure';
|
||||||
}
|
}
|
||||||
|
|
@ -249,6 +253,13 @@ const StatusBadge = ({ status }: { status: TeamStatus }): React.JSX.Element => {
|
||||||
Launch failed partway
|
Launch failed partway
|
||||||
</span>
|
</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':
|
case 'partial_pending':
|
||||||
return (
|
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">
|
<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">
|
<div className="flex shrink-0 gap-1">
|
||||||
{(status === 'offline' ||
|
{(status === 'offline' ||
|
||||||
status === 'partial_failure' ||
|
status === 'partial_failure' ||
|
||||||
|
status === 'partial_skipped' ||
|
||||||
status === 'partial_pending') &&
|
status === 'partial_pending') &&
|
||||||
team.projectPath && (
|
team.projectPath && (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -981,12 +993,12 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
{team.teamLaunchState === 'partial_pending' ? (
|
{team.teamLaunchState === 'partial_pending' ? (
|
||||||
<p className="mt-2 text-[11px] text-amber-300">
|
<p className="mt-2 text-[11px] text-amber-300">
|
||||||
{team.runtimeAlivePendingCount && team.runtimeAlivePendingCount > 0
|
{team.runtimeProcessPendingCount && team.runtimeProcessPendingCount > 0
|
||||||
? buildPendingRuntimeSummaryCopy({
|
? buildPendingRuntimeSummaryCopy({
|
||||||
confirmedCount: team.confirmedCount,
|
confirmedCount: team.confirmedCount,
|
||||||
expectedMemberCount: team.expectedMemberCount,
|
expectedMemberCount: team.expectedMemberCount,
|
||||||
memberCount: team.memberCount,
|
memberCount: team.memberCount,
|
||||||
runtimeAlivePendingCount: team.runtimeAlivePendingCount,
|
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
|
||||||
includePeriod: true,
|
includePeriod: true,
|
||||||
})
|
})
|
||||||
: 'Last launch is still reconciling.'}
|
: '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 ${team.missingMembers.length}/${team.expectedMemberCount ?? team.missingMembers.length} teammate${team.missingMembers.length === 1 ? '' : 's'} joined.`
|
||||||
: 'Last launch stopped before all teammates joined.'}
|
: 'Last launch stopped before all teammates joined.'}
|
||||||
</p>
|
</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}
|
) : null}
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
<div className="mt-3 flex flex-wrap items-center gap-1.5">
|
||||||
{team.members && team.members.length > 0 ? (
|
{team.members && team.members.length > 0 ? (
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,7 @@ export const TeamProvisioningPanel = memo(function TeamProvisioningPanel({
|
||||||
pid={presentation.progress.pid}
|
pid={presentation.progress.pid}
|
||||||
cliLogsTail={presentation.progress.cliLogsTail}
|
cliLogsTail={presentation.progress.cliLogsTail}
|
||||||
assistantOutput={presentation.progress.assistantOutput}
|
assistantOutput={presentation.progress.assistantOutput}
|
||||||
|
launchDiagnostics={presentation.progress.launchDiagnostics}
|
||||||
defaultLiveOutputOpen={presentation.defaultLiveOutputOpen}
|
defaultLiveOutputOpen={presentation.defaultLiveOutputOpen}
|
||||||
defaultLogsOpen={defaultLogsOpen}
|
defaultLogsOpen={defaultLogsOpen}
|
||||||
onCancel={
|
onCancel={
|
||||||
|
|
|
||||||
|
|
@ -87,6 +87,8 @@ import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
|
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
||||||
|
import { CodexFastModeSelector } from './CodexFastModeSelector';
|
||||||
import {
|
import {
|
||||||
clearInheritedMemberModelsUnavailableForProvider,
|
clearInheritedMemberModelsUnavailableForProvider,
|
||||||
resolveProviderScopedMemberModel,
|
resolveProviderScopedMemberModel,
|
||||||
|
|
@ -1423,10 +1425,12 @@ export const CreateTeamDialog = ({
|
||||||
const summary: string[] = [];
|
const summary: string[] = [];
|
||||||
if (prompt.trim()) summary.push('Lead prompt');
|
if (prompt.trim()) summary.push('Lead prompt');
|
||||||
if (skipPermissions) summary.push('Auto-approve tools');
|
if (skipPermissions) summary.push('Auto-approve tools');
|
||||||
if (selectedProviderId === 'anthropic') {
|
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
|
||||||
if (selectedFastMode === 'on') summary.push('Fast mode');
|
if (selectedFastMode === 'on') summary.push('Fast mode');
|
||||||
else if (selectedFastMode === 'off') summary.push('Fast disabled');
|
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 (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
||||||
if (customArgs.trim()) summary.push('Custom CLI args');
|
if (customArgs.trim()) summary.push('Custom CLI args');
|
||||||
|
|
@ -1723,9 +1727,6 @@ export const CreateTeamDialog = ({
|
||||||
onProviderChange={setSelectedProviderId}
|
onProviderChange={setSelectedProviderId}
|
||||||
onModelChange={setSelectedModel}
|
onModelChange={setSelectedModel}
|
||||||
onEffortChange={setSelectedEffort}
|
onEffortChange={setSelectedEffort}
|
||||||
fastMode={selectedFastMode}
|
|
||||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
|
||||||
onFastModeChange={setSelectedFastMode}
|
|
||||||
onLimitContextChange={setLimitContext}
|
onLimitContextChange={setLimitContext}
|
||||||
syncModelsWithTeammates={syncModelsWithLead}
|
syncModelsWithTeammates={syncModelsWithLead}
|
||||||
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
||||||
|
|
@ -1734,7 +1735,6 @@ export const CreateTeamDialog = ({
|
||||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||||
disableGeminiOption={isGeminiUiFrozen()}
|
disableGeminiOption={isGeminiUiFrozen()}
|
||||||
leadModelIssueText={leadModelIssueText}
|
leadModelIssueText={leadModelIssueText}
|
||||||
leadFastModeNotice={anthropicRuntimeNotice}
|
|
||||||
memberModelIssueById={memberModelIssueById}
|
memberModelIssueById={memberModelIssueById}
|
||||||
headerTop={
|
headerTop={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1820,6 +1820,47 @@ export const CreateTeamDialog = ({
|
||||||
summary={launchOptionalSummary}
|
summary={launchOptionalSummary}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<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">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="team-prompt" className="label-optional">
|
<Label htmlFor="team-prompt" className="label-optional">
|
||||||
Prompt for team lead (optional)
|
Prompt for team lead (optional)
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,7 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
X,
|
X,
|
||||||
|
|
@ -1633,10 +1634,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
|
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
|
||||||
if (selectedModel) summary.push(`Model: ${selectedModel}`);
|
if (selectedModel) summary.push(`Model: ${selectedModel}`);
|
||||||
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
|
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
|
||||||
if (selectedProviderId === 'anthropic') {
|
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
|
||||||
if (selectedFastMode === 'on') summary.push('Fast mode');
|
if (selectedFastMode === 'on') summary.push('Fast mode');
|
||||||
else if (selectedFastMode === 'off') summary.push('Fast disabled');
|
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 (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context');
|
||||||
if (skipPermissions) summary.push('Auto-approve tools');
|
if (skipPermissions) summary.push('Auto-approve tools');
|
||||||
|
|
@ -2278,6 +2281,52 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
summary={launchOptionalSummary}
|
summary={launchOptionalSummary}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<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
|
<TeamRosterEditorSection
|
||||||
members={membersDrafts}
|
members={membersDrafts}
|
||||||
onMembersChange={setMembersDrafts}
|
onMembersChange={setMembersDrafts}
|
||||||
|
|
@ -2300,13 +2349,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
providerId={selectedProviderId}
|
providerId={selectedProviderId}
|
||||||
model={selectedModel}
|
model={selectedModel}
|
||||||
effort={(selectedEffort as EffortLevel) || undefined}
|
effort={(selectedEffort as EffortLevel) || undefined}
|
||||||
fastMode={selectedFastMode}
|
|
||||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
|
||||||
limitContext={limitContext}
|
limitContext={limitContext}
|
||||||
onProviderChange={setSelectedProviderId}
|
onProviderChange={setSelectedProviderId}
|
||||||
onModelChange={setSelectedModel}
|
onModelChange={setSelectedModel}
|
||||||
onEffortChange={setSelectedEffort}
|
onEffortChange={setSelectedEffort}
|
||||||
onFastModeChange={setSelectedFastMode}
|
|
||||||
onLimitContextChange={setLimitContext}
|
onLimitContextChange={setLimitContext}
|
||||||
syncModelsWithTeammates={syncModelsWithLead}
|
syncModelsWithTeammates={syncModelsWithLead}
|
||||||
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||||
|
|
@ -2314,7 +2360,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||||
leadWarningText={leadRuntimeWarningText}
|
leadWarningText={leadRuntimeWarningText}
|
||||||
leadFastModeNotice={anthropicRuntimeNotice}
|
|
||||||
memberWarningById={memberRuntimeWarningById}
|
memberWarningById={memberRuntimeWarningById}
|
||||||
leadModelIssueText={leadModelIssueText}
|
leadModelIssueText={leadModelIssueText}
|
||||||
memberModelIssueById={memberModelIssueById}
|
memberModelIssueById={memberModelIssueById}
|
||||||
|
|
|
||||||
|
|
@ -492,6 +492,20 @@ function resolveModelResultFromCompatibilityBatch(
|
||||||
? (getResultReason(modelId, result) ?? normalizeModelReason(result.message))
|
? (getResultReason(modelId, result) ?? normalizeModelReason(result.message))
|
||||||
: null;
|
: 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) =>
|
const hasCompatibilityLine = modelScopedEntries.some((entry) =>
|
||||||
/selected model .* is compatible\. deep verification pending\./i.test(entry)
|
/selected model .* is compatible\. deep verification pending\./i.test(entry)
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
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 { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
||||||
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
|
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
|
||||||
import {
|
import {
|
||||||
|
|
@ -24,24 +22,20 @@ import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '../../ui/button';
|
import { Button } from '../../ui/button';
|
||||||
|
|
||||||
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
|
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
interface LeadModelRowProps {
|
interface LeadModelRowProps {
|
||||||
providerId: TeamProviderId;
|
providerId: TeamProviderId;
|
||||||
model: string;
|
model: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
fastMode?: TeamFastMode;
|
|
||||||
providerFastModeDefault?: boolean;
|
|
||||||
limitContext: boolean;
|
limitContext: boolean;
|
||||||
onProviderChange: (providerId: TeamProviderId) => void;
|
onProviderChange: (providerId: TeamProviderId) => void;
|
||||||
onModelChange: (model: string) => void;
|
onModelChange: (model: string) => void;
|
||||||
onEffortChange: (effort: string) => void;
|
onEffortChange: (effort: string) => void;
|
||||||
onFastModeChange?: (fastMode: TeamFastMode) => void;
|
|
||||||
onLimitContextChange: (value: boolean) => void;
|
onLimitContextChange: (value: boolean) => void;
|
||||||
syncModelsWithTeammates: boolean;
|
syncModelsWithTeammates: boolean;
|
||||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||||
warningText?: string | null;
|
warningText?: string | null;
|
||||||
fastModeNotice?: string | null;
|
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
modelIssueText?: string | null;
|
modelIssueText?: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -50,18 +44,14 @@ export const LeadModelRow = ({
|
||||||
providerId,
|
providerId,
|
||||||
model,
|
model,
|
||||||
effort,
|
effort,
|
||||||
fastMode = 'inherit',
|
|
||||||
providerFastModeDefault = false,
|
|
||||||
limitContext,
|
limitContext,
|
||||||
onProviderChange,
|
onProviderChange,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
onEffortChange,
|
onEffortChange,
|
||||||
onFastModeChange,
|
|
||||||
onLimitContextChange,
|
onLimitContextChange,
|
||||||
syncModelsWithTeammates,
|
syncModelsWithTeammates,
|
||||||
onSyncModelsWithTeammatesChange,
|
onSyncModelsWithTeammatesChange,
|
||||||
warningText,
|
warningText,
|
||||||
fastModeNotice,
|
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
modelIssueText,
|
modelIssueText,
|
||||||
}: LeadModelRowProps): React.JSX.Element => {
|
}: LeadModelRowProps): React.JSX.Element => {
|
||||||
|
|
@ -173,24 +163,6 @@ export const LeadModelRow = ({
|
||||||
model={model}
|
model={model}
|
||||||
limitContext={limitContext}
|
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' ? (
|
{providerId === 'anthropic' ? (
|
||||||
<LimitContextCheckbox
|
<LimitContextCheckbox
|
||||||
id="lead-limit-context"
|
id="lead-limit-context"
|
||||||
|
|
@ -199,12 +171,6 @@ export const LeadModelRow = ({
|
||||||
disabled={isAnthropicHaikuTeamModel(model)}
|
disabled={isAnthropicHaikuTeamModel(model)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : 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">
|
<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" />
|
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
<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 { Badge } from '@renderer/components/ui/badge';
|
||||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
|
@ -13,10 +13,26 @@ import {
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} 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 { 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 { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
|
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -24,7 +40,9 @@ import type {
|
||||||
MemberLaunchState,
|
MemberLaunchState,
|
||||||
MemberSpawnLivenessSource,
|
MemberSpawnLivenessSource,
|
||||||
MemberSpawnStatus,
|
MemberSpawnStatus,
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
|
TeamAgentRuntimeEntry,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -32,6 +50,8 @@ interface MemberCardProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
memberColor: string;
|
memberColor: string;
|
||||||
runtimeSummary?: string;
|
runtimeSummary?: string;
|
||||||
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
|
runtimeRunId?: string | null;
|
||||||
taskCounts?: TaskStatusCounts | null;
|
taskCounts?: TaskStatusCounts | null;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
|
@ -41,6 +61,7 @@ interface MemberCardProps {
|
||||||
isAwaitingReply?: boolean;
|
isAwaitingReply?: boolean;
|
||||||
isRemoved?: boolean;
|
isRemoved?: boolean;
|
||||||
spawnStatus?: MemberSpawnStatus;
|
spawnStatus?: MemberSpawnStatus;
|
||||||
|
spawnEntry?: MemberSpawnStatusEntry;
|
||||||
spawnError?: string;
|
spawnError?: string;
|
||||||
spawnLivenessSource?: MemberSpawnLivenessSource;
|
spawnLivenessSource?: MemberSpawnLivenessSource;
|
||||||
spawnLaunchState?: MemberLaunchState;
|
spawnLaunchState?: MemberLaunchState;
|
||||||
|
|
@ -51,6 +72,8 @@ interface MemberCardProps {
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
onSendMessage?: () => void;
|
onSendMessage?: () => void;
|
||||||
onAssignTask?: () => void;
|
onAssignTask?: () => void;
|
||||||
|
onRestartMember?: (memberName: string) => Promise<void> | void;
|
||||||
|
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
||||||
|
|
@ -77,6 +100,8 @@ export const MemberCard = ({
|
||||||
member,
|
member,
|
||||||
memberColor,
|
memberColor,
|
||||||
runtimeSummary,
|
runtimeSummary,
|
||||||
|
runtimeEntry,
|
||||||
|
runtimeRunId,
|
||||||
taskCounts,
|
taskCounts,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
|
@ -86,6 +111,7 @@ export const MemberCard = ({
|
||||||
isAwaitingReply,
|
isAwaitingReply,
|
||||||
isRemoved,
|
isRemoved,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
spawnEntry,
|
||||||
spawnError,
|
spawnError,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
|
|
@ -96,6 +122,8 @@ export const MemberCard = ({
|
||||||
onClick,
|
onClick,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
|
onRestartMember,
|
||||||
|
onSkipMemberForLaunch,
|
||||||
}: MemberCardProps): React.JSX.Element => {
|
}: MemberCardProps): React.JSX.Element => {
|
||||||
// NOTE: lead context display disabled — usage formula is inaccurate
|
// NOTE: lead context display disabled — usage formula is inaccurate
|
||||||
// const teamName = useStore((s) => s.selectedTeamName);
|
// const teamName = useStore((s) => s.selectedTeamName);
|
||||||
|
|
@ -103,6 +131,10 @@ export const MemberCard = ({
|
||||||
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
// member.agentType === 'team-lead' && teamName ? s.leadContextByTeam[teamName] : undefined
|
||||||
// );
|
// );
|
||||||
const selectedTeamName = useStore((s) => s.selectedTeamName);
|
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) =>
|
const teamMembers = useStore((s) =>
|
||||||
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
selectedTeamName ? selectResolvedMembersForTeamName(s, selectedTeamName) : []
|
||||||
);
|
);
|
||||||
|
|
@ -113,6 +145,7 @@ export const MemberCard = ({
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
runtimeEntry,
|
||||||
runtimeAdvisory: member.runtimeAdvisory,
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
|
|
@ -128,7 +161,12 @@ export const MemberCard = ({
|
||||||
const launchVisualState = launchPresentation.launchVisualState;
|
const launchVisualState = launchPresentation.launchVisualState;
|
||||||
const launchStatusLabel = launchPresentation.launchStatusLabel;
|
const launchStatusLabel = launchPresentation.launchStatusLabel;
|
||||||
const displayPresenceLabel =
|
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)
|
? (launchStatusLabel ?? presenceLabel)
|
||||||
: presenceLabel;
|
: presenceLabel;
|
||||||
const colors = getTeamColorSet(memberColor);
|
const colors = getTeamColorSet(memberColor);
|
||||||
|
|
@ -141,6 +179,7 @@ export const MemberCard = ({
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||||
const { summary: runtimeSummaryText, memory: memoryLabel } =
|
const { summary: runtimeSummaryText, memory: memoryLabel } =
|
||||||
splitRuntimeSummaryMemory(runtimeSummary);
|
splitRuntimeSummaryMemory(runtimeSummary);
|
||||||
|
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
||||||
const activityTask = currentTask ?? reviewTask ?? null;
|
const activityTask = currentTask ?? reviewTask ?? null;
|
||||||
const activityTitle = currentTask
|
const activityTitle = currentTask
|
||||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||||
|
|
@ -159,14 +198,101 @@ export const MemberCard = ({
|
||||||
!runtimeAdvisoryLabel &&
|
!runtimeAdvisoryLabel &&
|
||||||
(presenceLabel === 'starting' ||
|
(presenceLabel === 'starting' ||
|
||||||
presenceLabel === 'connecting' ||
|
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 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 =
|
const showRuntimeAdvisoryBadge =
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
Boolean(runtimeAdvisoryLabel) &&
|
Boolean(runtimeAdvisoryLabel) &&
|
||||||
!showLaunchBadge &&
|
!showLaunchBadge &&
|
||||||
spawnStatus !== 'error' &&
|
!isFailedLaunch &&
|
||||||
|
!isSkippedLaunch &&
|
||||||
(Boolean(activityTask) || !isAwaitingReply);
|
(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 (
|
return (
|
||||||
<div
|
<div
|
||||||
|
|
@ -283,12 +409,19 @@ export const MemberCard = ({
|
||||||
{(runtimeSummaryText || roleLabel) && memoryLabel ? (
|
{(runtimeSummaryText || roleLabel) && memoryLabel ? (
|
||||||
<span className="shrink-0 opacity-60">•</span>
|
<span className="shrink-0 opacity-60">•</span>
|
||||||
) : null}
|
) : null}
|
||||||
{memoryLabel ? <span className="shrink-0">{memoryLabel}</span> : null}
|
{memoryLabel ? (
|
||||||
|
<span className="shrink-0" title={memorySourceLabel}>
|
||||||
|
{memoryLabel}
|
||||||
|
</span>
|
||||||
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
{showLaunchBadge ? (
|
{showLaunchBadge ? (
|
||||||
<span className="flex shrink-0 items-center gap-1">
|
<span
|
||||||
|
className="flex shrink-0 items-center gap-1"
|
||||||
|
title={runtimeEntry?.runtimeDiagnostic}
|
||||||
|
>
|
||||||
<Loader2
|
<Loader2
|
||||||
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
|
className="size-3.5 shrink-0 animate-spin text-[var(--color-text-muted)]"
|
||||||
aria-label={launchBadgeLabel}
|
aria-label={launchBadgeLabel}
|
||||||
|
|
@ -300,7 +433,8 @@ export const MemberCard = ({
|
||||||
{launchBadgeLabel}
|
{launchBadgeLabel}
|
||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
) : spawnStatus === 'error' ? (
|
) : showFailedLaunchBadge ? (
|
||||||
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<span className="flex shrink-0 items-center gap-1">
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
|
|
@ -315,6 +449,101 @@ export const MemberCard = ({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
|
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
|
||||||
</Tooltip>
|
</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 ? (
|
) : showRuntimeAdvisoryBadge ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
|
||||||
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
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 { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
|
@ -22,6 +31,7 @@ import { buildMemberActivityEntries } from './memberActivityEntries';
|
||||||
import { MemberDetailHeader } from './MemberDetailHeader';
|
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||||
import { MemberDetailStats } from './MemberDetailStats';
|
import { MemberDetailStats } from './MemberDetailStats';
|
||||||
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
||||||
|
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||||
import { MemberLogsTab } from './MemberLogsTab';
|
import { MemberLogsTab } from './MemberLogsTab';
|
||||||
import { MemberMessagesTab } from './MemberMessagesTab';
|
import { MemberMessagesTab } from './MemberMessagesTab';
|
||||||
import { MemberStatsTab } from './MemberStatsTab';
|
import { MemberStatsTab } from './MemberStatsTab';
|
||||||
|
|
@ -50,6 +60,7 @@ interface MemberDetailDialogProps {
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
spawnEntry?: MemberSpawnStatusEntry;
|
spawnEntry?: MemberSpawnStatusEntry;
|
||||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
|
runtimeRunId?: string | null;
|
||||||
launchParams?: TeamLaunchParams;
|
launchParams?: TeamLaunchParams;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSendMessage: () => void;
|
onSendMessage: () => void;
|
||||||
|
|
@ -76,6 +87,7 @@ export const MemberDetailDialog = ({
|
||||||
leadActivity,
|
leadActivity,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
|
runtimeRunId,
|
||||||
launchParams,
|
launchParams,
|
||||||
onClose,
|
onClose,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
|
@ -128,10 +140,31 @@ export const MemberDetailDialog = ({
|
||||||
: undefined,
|
: undefined,
|
||||||
[launchParams, member, runtimeEntry, spawnEntry]
|
[launchParams, member, runtimeEntry, spawnEntry]
|
||||||
);
|
);
|
||||||
|
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
||||||
const restartInFlight =
|
const restartInFlight =
|
||||||
spawnEntry?.launchState === 'starting' ||
|
spawnEntry?.launchState === 'starting' ||
|
||||||
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
|
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
|
||||||
spawnEntry?.launchState === 'runtime_pending_permission';
|
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(() => {
|
useEffect(() => {
|
||||||
if (!open || !member) {
|
if (!open || !member) {
|
||||||
|
|
@ -167,6 +200,7 @@ export const MemberDetailDialog = ({
|
||||||
spawnLaunchState={spawnEntry?.launchState}
|
spawnLaunchState={spawnEntry?.launchState}
|
||||||
spawnLivenessSource={spawnEntry?.livenessSource}
|
spawnLivenessSource={spawnEntry?.livenessSource}
|
||||||
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
||||||
|
runtimeEntry={runtimeEntry}
|
||||||
isLaunchSettling={isLaunchSettling}
|
isLaunchSettling={isLaunchSettling}
|
||||||
onUpdateRole={
|
onUpdateRole={
|
||||||
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
onUpdateRole ? (newRole) => onUpdateRole(member.name, newRole) : undefined
|
||||||
|
|
@ -250,9 +284,23 @@ export const MemberDetailDialog = ({
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{restartError ? (
|
{restartError ? (
|
||||||
<div className="mr-auto text-xs text-red-400">{restartError}</div>
|
<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 ? (
|
) : runtimeEntry?.pid ? (
|
||||||
<div className="mr-auto text-xs text-[var(--color-text-muted)]">
|
<div className="mr-auto text-xs text-[var(--color-text-muted)]">
|
||||||
PID {runtimeEntry.pid}
|
PID {runtimeEntry.pid}
|
||||||
|
{memorySourceLabel ? ` · ${memorySourceLabel}` : ''}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mr-auto" />
|
<div className="mr-auto" />
|
||||||
|
|
|
||||||
|
|
@ -23,11 +23,13 @@ import type {
|
||||||
MemberSpawnLivenessSource,
|
MemberSpawnLivenessSource,
|
||||||
MemberSpawnStatus,
|
MemberSpawnStatus,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
|
TeamAgentRuntimeEntry,
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
interface MemberDetailHeaderProps {
|
interface MemberDetailHeaderProps {
|
||||||
member: ResolvedTeamMember;
|
member: ResolvedTeamMember;
|
||||||
runtimeSummary?: string;
|
runtimeSummary?: string;
|
||||||
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
|
|
@ -43,6 +45,7 @@ interface MemberDetailHeaderProps {
|
||||||
export const MemberDetailHeader = ({
|
export const MemberDetailHeader = ({
|
||||||
member,
|
member,
|
||||||
runtimeSummary,
|
runtimeSummary,
|
||||||
|
runtimeEntry,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
|
|
@ -75,6 +78,7 @@ export const MemberDetailHeader = ({
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
|
runtimeEntry,
|
||||||
runtimeAdvisory: member.runtimeAdvisory,
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
|
|
@ -91,7 +95,12 @@ export const MemberDetailHeader = ({
|
||||||
const badgeLabel =
|
const badgeLabel =
|
||||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||||
? 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)
|
? (launchStatusLabel ?? presenceLabel)
|
||||||
: presenceLabel;
|
: presenceLabel;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ import {
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import {
|
||||||
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
|
getMemberLaunchDiagnosticsErrorMessage,
|
||||||
|
hasMemberLaunchDiagnosticsDetails,
|
||||||
|
hasMemberLaunchDiagnosticsError,
|
||||||
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -29,6 +35,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
||||||
|
|
||||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
|
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||||
|
|
||||||
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -68,6 +75,8 @@ export const MemberHoverCard = ({
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
|
runtimeRunId,
|
||||||
|
runtimeEntry,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
|
|
@ -89,6 +98,12 @@ export const MemberHoverCard = ({
|
||||||
spawnEntry: effectiveTeamName
|
spawnEntry: effectiveTeamName
|
||||||
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
|
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
|
||||||
: undefined,
|
: undefined,
|
||||||
|
runtimeRunId: effectiveTeamName
|
||||||
|
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.runId
|
||||||
|
: undefined,
|
||||||
|
runtimeEntry: effectiveTeamName
|
||||||
|
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.members[name]
|
||||||
|
: undefined,
|
||||||
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
|
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
@ -114,6 +129,7 @@ export const MemberHoverCard = ({
|
||||||
spawnLaunchState: spawnEntry?.launchState,
|
spawnLaunchState: spawnEntry?.launchState,
|
||||||
spawnLivenessSource: spawnEntry?.livenessSource,
|
spawnLivenessSource: spawnEntry?.livenessSource,
|
||||||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||||
|
runtimeEntry,
|
||||||
runtimeAdvisory: member.runtimeAdvisory,
|
runtimeAdvisory: member.runtimeAdvisory,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
|
|
@ -130,9 +146,25 @@ export const MemberHoverCard = ({
|
||||||
const badgeLabel =
|
const badgeLabel =
|
||||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||||
? 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)
|
? (launchStatusLabel ?? presenceLabel)
|
||||||
: 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
|
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
||||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -226,10 +258,24 @@ export const MemberHoverCard = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Open profile button */}
|
{launchErrorMessage ? (
|
||||||
|
<div className="flex items-center gap-2 rounded border border-red-500/25 bg-red-500/10 px-2 py-1.5 text-xs text-red-300">
|
||||||
|
<span className="min-w-0 flex-1 truncate" title={launchErrorMessage}>
|
||||||
|
{launchErrorMessage}
|
||||||
|
</span>
|
||||||
|
{showCopyDiagnostics ? (
|
||||||
|
<MemberLaunchDiagnosticsButton
|
||||||
|
payload={launchDiagnosticsPayload}
|
||||||
|
className="h-auto shrink-0 rounded px-1.5 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
|
<div className="flex gap-1.5">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
className="flex w-full items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
openMemberProfile(member.name);
|
openMemberProfile(member.name);
|
||||||
|
|
@ -239,6 +285,7 @@ export const MemberHoverCard = ({
|
||||||
Open profile
|
Open profile
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</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>;
|
pendingRepliesByMember?: Record<string, number>;
|
||||||
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
|
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
|
||||||
memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>;
|
memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>;
|
||||||
|
runtimeRunId?: string | null;
|
||||||
isLaunchSettling?: boolean;
|
isLaunchSettling?: boolean;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
|
@ -32,6 +33,8 @@ interface MemberListProps {
|
||||||
onSendMessage?: (member: ResolvedTeamMember) => void;
|
onSendMessage?: (member: ResolvedTeamMember) => void;
|
||||||
onAssignTask?: (member: ResolvedTeamMember) => void;
|
onAssignTask?: (member: ResolvedTeamMember) => void;
|
||||||
onOpenTask?: (taskId: string) => void;
|
onOpenTask?: (taskId: string) => void;
|
||||||
|
onRestartMember?: (memberName: string) => Promise<void> | void;
|
||||||
|
onSkipMemberForLaunch?: (memberName: string) => Promise<void> | void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function areResolvedMembersEquivalent(
|
function areResolvedMembersEquivalent(
|
||||||
|
|
@ -150,7 +153,13 @@ function areMemberSpawnStatusesEquivalent(
|
||||||
leftEntry.error !== rightEntry.error ||
|
leftEntry.error !== rightEntry.error ||
|
||||||
leftEntry.hardFailure !== rightEntry.hardFailure ||
|
leftEntry.hardFailure !== rightEntry.hardFailure ||
|
||||||
leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
|
leftEntry.hardFailureReason !== rightEntry.hardFailureReason ||
|
||||||
|
leftEntry.skippedForLaunch !== rightEntry.skippedForLaunch ||
|
||||||
|
leftEntry.skipReason !== rightEntry.skipReason ||
|
||||||
|
leftEntry.skippedAt !== rightEntry.skippedAt ||
|
||||||
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
leftEntry.livenessSource !== rightEntry.livenessSource ||
|
||||||
|
leftEntry.livenessKind !== rightEntry.livenessKind ||
|
||||||
|
leftEntry.runtimeDiagnostic !== rightEntry.runtimeDiagnostic ||
|
||||||
|
leftEntry.runtimeDiagnosticSeverity !== rightEntry.runtimeDiagnosticSeverity ||
|
||||||
leftEntry.runtimeModel !== rightEntry.runtimeModel ||
|
leftEntry.runtimeModel !== rightEntry.runtimeModel ||
|
||||||
leftEntry.runtimeAlive !== rightEntry.runtimeAlive ||
|
leftEntry.runtimeAlive !== rightEntry.runtimeAlive ||
|
||||||
leftEntry.bootstrapConfirmed !== rightEntry.bootstrapConfirmed ||
|
leftEntry.bootstrapConfirmed !== rightEntry.bootstrapConfirmed ||
|
||||||
|
|
@ -189,14 +198,34 @@ function areMemberRuntimeEntriesEquivalent(
|
||||||
if (left.size !== right.size) return false;
|
if (left.size !== right.size) return false;
|
||||||
for (const [key, leftEntry] of left) {
|
for (const [key, leftEntry] of left) {
|
||||||
const rightEntry = right.get(key);
|
const rightEntry = right.get(key);
|
||||||
|
const leftDiagnostics = leftEntry.diagnostics ?? [];
|
||||||
|
const rightDiagnostics = rightEntry?.diagnostics ?? [];
|
||||||
if (
|
if (
|
||||||
leftEntry.memberName !== rightEntry?.memberName ||
|
leftEntry.memberName !== rightEntry?.memberName ||
|
||||||
leftEntry.alive !== rightEntry?.alive ||
|
leftEntry.alive !== rightEntry?.alive ||
|
||||||
leftEntry.restartable !== rightEntry?.restartable ||
|
leftEntry.restartable !== rightEntry?.restartable ||
|
||||||
leftEntry.backendType !== rightEntry?.backendType ||
|
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.pid !== rightEntry?.pid ||
|
||||||
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -215,10 +244,13 @@ function areMemberListPropsEqual(
|
||||||
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
|
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
|
||||||
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
|
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
|
||||||
areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) &&
|
areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) &&
|
||||||
|
prev.runtimeRunId === next.runtimeRunId &&
|
||||||
prev.isLaunchSettling === next.isLaunchSettling &&
|
prev.isLaunchSettling === next.isLaunchSettling &&
|
||||||
prev.isTeamAlive === next.isTeamAlive &&
|
prev.isTeamAlive === next.isTeamAlive &&
|
||||||
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
||||||
prev.leadActivity === next.leadActivity &&
|
prev.leadActivity === next.leadActivity &&
|
||||||
|
prev.onRestartMember === next.onRestartMember &&
|
||||||
|
prev.onSkipMemberForLaunch === next.onSkipMemberForLaunch &&
|
||||||
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
|
areLaunchParamsEquivalent(prev.launchParams, next.launchParams)
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -230,6 +262,7 @@ export const MemberList = memo(function MemberList({
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberRuntimeEntries,
|
memberRuntimeEntries,
|
||||||
|
runtimeRunId,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
|
@ -239,6 +272,8 @@ export const MemberList = memo(function MemberList({
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
onAssignTask,
|
onAssignTask,
|
||||||
onOpenTask,
|
onOpenTask,
|
||||||
|
onRestartMember,
|
||||||
|
onSkipMemberForLaunch,
|
||||||
}: MemberListProps): React.JSX.Element {
|
}: MemberListProps): React.JSX.Element {
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
const containerRef = useRef<HTMLDivElement>(null);
|
||||||
const [isWide, setIsWide] = useState(false);
|
const [isWide, setIsWide] = useState(false);
|
||||||
|
|
@ -332,7 +367,10 @@ export const MemberList = memo(function MemberList({
|
||||||
isRemoved ? undefined : spawnEntry,
|
isRemoved ? undefined : spawnEntry,
|
||||||
isRemoved ? undefined : runtimeEntry
|
isRemoved ? undefined : runtimeEntry
|
||||||
)}
|
)}
|
||||||
|
runtimeEntry={isRemoved ? undefined : runtimeEntry}
|
||||||
|
runtimeRunId={isRemoved ? undefined : runtimeRunId}
|
||||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||||
|
spawnEntry={isRemoved ? undefined : spawnEntry}
|
||||||
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
|
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
|
||||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||||
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
||||||
|
|
@ -343,6 +381,8 @@ export const MemberList = memo(function MemberList({
|
||||||
onClick={() => onMemberClick?.(member)}
|
onClick={() => onMemberClick?.(member)}
|
||||||
onSendMessage={() => onSendMessage?.(member)}
|
onSendMessage={() => onSendMessage?.(member)}
|
||||||
onAssignTask={() => onAssignTask?.(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 { MemberDraft } from './membersEditorTypes';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
|
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
interface TeamRosterEditorSectionProps {
|
interface TeamRosterEditorSectionProps {
|
||||||
members: MemberDraft[];
|
members: MemberDraft[];
|
||||||
|
|
@ -31,13 +31,10 @@ interface TeamRosterEditorSectionProps {
|
||||||
providerId: TeamProviderId;
|
providerId: TeamProviderId;
|
||||||
model: string;
|
model: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
fastMode?: TeamFastMode;
|
|
||||||
providerFastModeDefault?: boolean;
|
|
||||||
limitContext: boolean;
|
limitContext: boolean;
|
||||||
onProviderChange: (providerId: TeamProviderId) => void;
|
onProviderChange: (providerId: TeamProviderId) => void;
|
||||||
onModelChange: (model: string) => void;
|
onModelChange: (model: string) => void;
|
||||||
onEffortChange: (effort: string) => void;
|
onEffortChange: (effort: string) => void;
|
||||||
onFastModeChange?: (fastMode: TeamFastMode) => void;
|
|
||||||
onLimitContextChange: (value: boolean) => void;
|
onLimitContextChange: (value: boolean) => void;
|
||||||
syncModelsWithTeammates: boolean;
|
syncModelsWithTeammates: boolean;
|
||||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||||
|
|
@ -45,7 +42,6 @@ interface TeamRosterEditorSectionProps {
|
||||||
headerBottom?: React.ReactNode;
|
headerBottom?: React.ReactNode;
|
||||||
softDeleteMembers?: boolean;
|
softDeleteMembers?: boolean;
|
||||||
leadWarningText?: string | null;
|
leadWarningText?: string | null;
|
||||||
leadFastModeNotice?: string | null;
|
|
||||||
memberWarningById?: Record<string, string | null | undefined>;
|
memberWarningById?: Record<string, string | null | undefined>;
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
leadModelIssueText?: string | null;
|
leadModelIssueText?: string | null;
|
||||||
|
|
@ -79,13 +75,10 @@ export const TeamRosterEditorSection = ({
|
||||||
providerId,
|
providerId,
|
||||||
model,
|
model,
|
||||||
effort,
|
effort,
|
||||||
fastMode,
|
|
||||||
providerFastModeDefault,
|
|
||||||
limitContext,
|
limitContext,
|
||||||
onProviderChange,
|
onProviderChange,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
onEffortChange,
|
onEffortChange,
|
||||||
onFastModeChange,
|
|
||||||
onLimitContextChange,
|
onLimitContextChange,
|
||||||
syncModelsWithTeammates,
|
syncModelsWithTeammates,
|
||||||
onSyncModelsWithTeammatesChange,
|
onSyncModelsWithTeammatesChange,
|
||||||
|
|
@ -93,7 +86,6 @@ export const TeamRosterEditorSection = ({
|
||||||
headerBottom,
|
headerBottom,
|
||||||
softDeleteMembers = false,
|
softDeleteMembers = false,
|
||||||
leadWarningText,
|
leadWarningText,
|
||||||
leadFastModeNotice,
|
|
||||||
memberWarningById,
|
memberWarningById,
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
leadModelIssueText,
|
leadModelIssueText,
|
||||||
|
|
@ -138,18 +130,14 @@ export const TeamRosterEditorSection = ({
|
||||||
providerId={providerId}
|
providerId={providerId}
|
||||||
model={model}
|
model={model}
|
||||||
effort={effort}
|
effort={effort}
|
||||||
fastMode={fastMode}
|
|
||||||
providerFastModeDefault={providerFastModeDefault}
|
|
||||||
limitContext={limitContext}
|
limitContext={limitContext}
|
||||||
onProviderChange={onProviderChange}
|
onProviderChange={onProviderChange}
|
||||||
onModelChange={onModelChange}
|
onModelChange={onModelChange}
|
||||||
onEffortChange={onEffortChange}
|
onEffortChange={onEffortChange}
|
||||||
onFastModeChange={onFastModeChange}
|
|
||||||
onLimitContextChange={onLimitContextChange}
|
onLimitContextChange={onLimitContextChange}
|
||||||
syncModelsWithTeammates={syncModelsWithTeammates}
|
syncModelsWithTeammates={syncModelsWithTeammates}
|
||||||
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
||||||
warningText={leadWarningText}
|
warningText={leadWarningText}
|
||||||
fastModeNotice={leadFastModeNotice}
|
|
||||||
disableGeminiOption={disableGeminiOption}
|
disableGeminiOption={disableGeminiOption}
|
||||||
modelIssueText={leadModelIssueText}
|
modelIssueText={leadModelIssueText}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,7 @@ export interface LaunchJoinMilestones {
|
||||||
processOnlyAliveCount: number;
|
processOnlyAliveCount: number;
|
||||||
pendingSpawnCount: number;
|
pendingSpawnCount: number;
|
||||||
failedSpawnCount: number;
|
failedSpawnCount: number;
|
||||||
|
skippedSpawnCount: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
type DisplayStepMilestones = LaunchJoinMilestones & {
|
type DisplayStepMilestones = LaunchJoinMilestones & {
|
||||||
|
|
@ -63,6 +64,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean
|
||||||
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
|
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolean {
|
||||||
|
return entry.runtimeAlive === true && entry.livenessKind === 'runtime_process';
|
||||||
|
}
|
||||||
|
|
||||||
function shouldPreferSnapshotEntryOverLive(
|
function shouldPreferSnapshotEntryOverLive(
|
||||||
liveEntry: MemberSpawnStatusEntry | undefined,
|
liveEntry: MemberSpawnStatusEntry | undefined,
|
||||||
snapshotEntry: MemberSpawnStatusEntry | undefined,
|
snapshotEntry: MemberSpawnStatusEntry | undefined,
|
||||||
|
|
@ -102,6 +107,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
||||||
let processOnlyAliveCount = 0;
|
let processOnlyAliveCount = 0;
|
||||||
let pendingSpawnCount = 0;
|
let pendingSpawnCount = 0;
|
||||||
let failedSpawnCount = 0;
|
let failedSpawnCount = 0;
|
||||||
|
let skippedSpawnCount = 0;
|
||||||
let observedTeammateCount = 0;
|
let observedTeammateCount = 0;
|
||||||
|
|
||||||
for (const memberName of teammateNames) {
|
for (const memberName of teammateNames) {
|
||||||
|
|
@ -123,15 +129,20 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
||||||
failedSpawnCount += 1;
|
failedSpawnCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
if (entry.launchState === 'skipped_for_launch' || entry.skippedForLaunch === true) {
|
||||||
|
skippedSpawnCount += 1;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
if (entry.launchState === 'confirmed_alive') {
|
if (entry.launchState === 'confirmed_alive') {
|
||||||
heartbeatConfirmedCount += 1;
|
heartbeatConfirmedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (entry.launchState === 'runtime_pending_permission') {
|
||||||
entry.launchState === 'runtime_pending_bootstrap' ||
|
pendingSpawnCount += 1;
|
||||||
entry.launchState === 'runtime_pending_permission'
|
continue;
|
||||||
) {
|
}
|
||||||
if (entry.runtimeAlive === true) {
|
if (entry.launchState === 'runtime_pending_bootstrap') {
|
||||||
|
if (isStrongRuntimeProcessSpawnEntry(entry)) {
|
||||||
processOnlyAliveCount += 1;
|
processOnlyAliveCount += 1;
|
||||||
} else {
|
} else {
|
||||||
pendingSpawnCount += 1;
|
pendingSpawnCount += 1;
|
||||||
|
|
@ -148,6 +159,7 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
observedTeammateCount,
|
observedTeammateCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
@ -196,28 +208,30 @@ export function getLaunchJoinMilestonesFromMembers({
|
||||||
});
|
});
|
||||||
|
|
||||||
if (snapshotSummary) {
|
if (snapshotSummary) {
|
||||||
|
const snapshotProcessOnlyAliveCount = snapshotSummary.runtimeProcessPendingCount ?? 0;
|
||||||
const snapshotMilestones = {
|
const snapshotMilestones = {
|
||||||
expectedTeammateCount,
|
expectedTeammateCount,
|
||||||
heartbeatConfirmedCount: snapshotSummary.confirmedCount,
|
heartbeatConfirmedCount: snapshotSummary.confirmedCount,
|
||||||
processOnlyAliveCount: snapshotSummary.runtimeAlivePendingCount,
|
processOnlyAliveCount: snapshotProcessOnlyAliveCount,
|
||||||
pendingSpawnCount: Math.max(
|
pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount),
|
||||||
0,
|
|
||||||
snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount
|
|
||||||
),
|
|
||||||
failedSpawnCount: snapshotSummary.failedCount,
|
failedSpawnCount: snapshotSummary.failedCount,
|
||||||
|
skippedSpawnCount: snapshotSummary.skippedCount ?? 0,
|
||||||
};
|
};
|
||||||
|
|
||||||
const snapshotAccountedFor =
|
const snapshotAccountedFor =
|
||||||
snapshotMilestones.heartbeatConfirmedCount +
|
snapshotMilestones.heartbeatConfirmedCount +
|
||||||
snapshotMilestones.processOnlyAliveCount +
|
snapshotMilestones.processOnlyAliveCount +
|
||||||
snapshotMilestones.failedSpawnCount;
|
snapshotMilestones.failedSpawnCount +
|
||||||
|
snapshotMilestones.skippedSpawnCount;
|
||||||
const liveAccountedFor =
|
const liveAccountedFor =
|
||||||
liveSummary.heartbeatConfirmedCount +
|
liveSummary.heartbeatConfirmedCount +
|
||||||
liveSummary.processOnlyAliveCount +
|
liveSummary.processOnlyAliveCount +
|
||||||
liveSummary.failedSpawnCount;
|
liveSummary.failedSpawnCount +
|
||||||
|
liveSummary.skippedSpawnCount;
|
||||||
|
|
||||||
const liveSummaryIsMoreAdvanced =
|
const liveSummaryIsMoreAdvanced =
|
||||||
liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount ||
|
liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount ||
|
||||||
|
liveSummary.skippedSpawnCount > snapshotMilestones.skippedSpawnCount ||
|
||||||
liveSummary.heartbeatConfirmedCount > snapshotMilestones.heartbeatConfirmedCount ||
|
liveSummary.heartbeatConfirmedCount > snapshotMilestones.heartbeatConfirmedCount ||
|
||||||
liveSummary.processOnlyAliveCount > snapshotMilestones.processOnlyAliveCount ||
|
liveSummary.processOnlyAliveCount > snapshotMilestones.processOnlyAliveCount ||
|
||||||
(snapshotMilestones.failedSpawnCount === 0 &&
|
(snapshotMilestones.failedSpawnCount === 0 &&
|
||||||
|
|
@ -245,6 +259,7 @@ export function getLaunchJoinState({
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
}: LaunchJoinMilestones): {
|
}: LaunchJoinMilestones): {
|
||||||
allTeammatesConfirmedAlive: boolean;
|
allTeammatesConfirmedAlive: boolean;
|
||||||
hasMembersStillJoining: boolean;
|
hasMembersStillJoining: boolean;
|
||||||
|
|
@ -253,14 +268,16 @@ export function getLaunchJoinState({
|
||||||
const allTeammatesConfirmedAlive =
|
const allTeammatesConfirmedAlive =
|
||||||
expectedTeammateCount > 0 &&
|
expectedTeammateCount > 0 &&
|
||||||
failedSpawnCount === 0 &&
|
failedSpawnCount === 0 &&
|
||||||
|
skippedSpawnCount === 0 &&
|
||||||
heartbeatConfirmedCount >= expectedTeammateCount;
|
heartbeatConfirmedCount >= expectedTeammateCount;
|
||||||
const remainingJoinCount =
|
const remainingJoinCount =
|
||||||
expectedTeammateCount > 0 && failedSpawnCount === 0
|
expectedTeammateCount > 0 && failedSpawnCount === 0 && skippedSpawnCount === 0
|
||||||
? Math.max(0, expectedTeammateCount - heartbeatConfirmedCount)
|
? Math.max(0, expectedTeammateCount - heartbeatConfirmedCount)
|
||||||
: 0;
|
: 0;
|
||||||
const hasMembersStillJoining =
|
const hasMembersStillJoining =
|
||||||
expectedTeammateCount > 0 &&
|
expectedTeammateCount > 0 &&
|
||||||
failedSpawnCount === 0 &&
|
failedSpawnCount === 0 &&
|
||||||
|
skippedSpawnCount === 0 &&
|
||||||
remainingJoinCount > 0 &&
|
remainingJoinCount > 0 &&
|
||||||
(processOnlyAliveCount > 0 || pendingSpawnCount > 0);
|
(processOnlyAliveCount > 0 || pendingSpawnCount > 0);
|
||||||
|
|
||||||
|
|
@ -292,6 +309,7 @@ export function getDisplayStepIndex({
|
||||||
processOnlyAliveCount,
|
processOnlyAliveCount,
|
||||||
pendingSpawnCount,
|
pendingSpawnCount,
|
||||||
failedSpawnCount,
|
failedSpawnCount,
|
||||||
|
skippedSpawnCount,
|
||||||
}: DisplayStepMilestones): number {
|
}: DisplayStepMilestones): number {
|
||||||
switch (progress.state) {
|
switch (progress.state) {
|
||||||
case 'ready':
|
case 'ready':
|
||||||
|
|
@ -319,8 +337,12 @@ export function getDisplayStepIndex({
|
||||||
if (failedSpawnCount > 0) {
|
if (failedSpawnCount > 0) {
|
||||||
return 2;
|
return 2;
|
||||||
}
|
}
|
||||||
|
if (skippedSpawnCount > 0) {
|
||||||
|
return 2;
|
||||||
|
}
|
||||||
|
|
||||||
const accountedForTeammates = heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount;
|
const accountedForTeammates =
|
||||||
|
heartbeatConfirmedCount + processOnlyAliveCount + failedSpawnCount + skippedSpawnCount;
|
||||||
|
|
||||||
if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) {
|
if (pendingSpawnCount > 0 || accountedForTeammates < expectedTeammateCount) {
|
||||||
return 2;
|
return 2;
|
||||||
|
|
|
||||||
|
|
@ -242,6 +242,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let teamMessageRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
let teamPresenceRefreshTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let memberSpawnRefreshTimers = 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 toolActivityTimers = new Map<string, ReturnType<typeof setTimeout>>();
|
||||||
let inProgressChangePresencePollInFlight = false;
|
let inProgressChangePresencePollInFlight = false;
|
||||||
let teamMessageFallbackPollInFlight = false;
|
let teamMessageFallbackPollInFlight = false;
|
||||||
|
|
@ -286,6 +287,19 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
}, TEAM_MEMBER_SPAWN_REFRESH_THROTTLE_MS);
|
||||||
memberSpawnRefreshTimers.set(teamName, timer);
|
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 => {
|
const scheduleTrackedTeamMessageRefresh = (teamName: string | null | undefined): void => {
|
||||||
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
if (!teamName || !shouldRefreshTeamMessages(teamName)) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -1194,6 +1208,7 @@ export function initializeNotificationListeners(): () => void {
|
||||||
}
|
}
|
||||||
seedCurrentRunIdIfMissing();
|
seedCurrentRunIdIfMissing();
|
||||||
scheduleMemberSpawnStatusesRefresh(event.teamName);
|
scheduleMemberSpawnStatusesRefresh(event.teamName);
|
||||||
|
scheduleTeamAgentRuntimeRefresh(event.teamName);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1276,6 +1291,8 @@ export function initializeNotificationListeners(): () => void {
|
||||||
teamPresenceRefreshTimers = new Map();
|
teamPresenceRefreshTimers = new Map();
|
||||||
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
|
for (const t of memberSpawnRefreshTimers.values()) clearTimeout(t);
|
||||||
memberSpawnRefreshTimers = new Map();
|
memberSpawnRefreshTimers = new Map();
|
||||||
|
for (const t of teamAgentRuntimeRefreshTimers.values()) clearTimeout(t);
|
||||||
|
teamAgentRuntimeRefreshTimers = new Map();
|
||||||
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
for (const t of toolActivityTimers.values()) clearTimeout(t);
|
||||||
toolActivityTimers = new Map();
|
toolActivityTimers = new Map();
|
||||||
teamLastRelevantActivityAt.clear();
|
teamLastRelevantActivityAt.clear();
|
||||||
|
|
|
||||||
|
|
@ -374,6 +374,19 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
set,
|
set,
|
||||||
get
|
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 = (
|
const buildResolvedFileInvalidation = (
|
||||||
s: ChangeReviewSlice,
|
s: ChangeReviewSlice,
|
||||||
filePath: string
|
filePath: string
|
||||||
|
|
@ -388,17 +401,10 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
const existing = findReviewFileByPath(s.activeChangeSet?.files, filePath);
|
const existing = findReviewFileByPath(s.activeChangeSet?.files, filePath);
|
||||||
const canonicalFilePath = existing?.filePath ?? filePath;
|
const canonicalFilePath = existing?.filePath ?? filePath;
|
||||||
const aliases = new Set([filePath, canonicalFilePath]);
|
const aliases = new Set([filePath, canonicalFilePath]);
|
||||||
const addMatchingAliases = (record: Record<string, unknown>): void => {
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
|
||||||
for (const key of Object.keys(record)) {
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
|
||||||
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentsLoading);
|
||||||
aliases.add(key);
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentVersionByPath);
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
addMatchingAliases(s.fileChunkCounts);
|
|
||||||
addMatchingAliases(s.fileContents);
|
|
||||||
addMatchingAliases(s.fileContentsLoading);
|
|
||||||
addMatchingAliases(s.fileContentVersionByPath);
|
|
||||||
const nextFileChunkCounts = { ...s.fileChunkCounts };
|
const nextFileChunkCounts = { ...s.fileChunkCounts };
|
||||||
for (const alias of aliases) delete nextFileChunkCounts[alias];
|
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);
|
await api.review.saveEditedFile(canonicalFilePath, content, projectPath);
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const aliases = new Set([filePath, canonicalFilePath]);
|
const aliases = new Set([filePath, canonicalFilePath]);
|
||||||
const addMatchingAliases = (record: Record<string, unknown>): void => {
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.editedContents);
|
||||||
for (const key of Object.keys(record)) {
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
|
||||||
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
|
addMatchingReviewPathAliases(
|
||||||
aliases.add(key);
|
aliases,
|
||||||
}
|
filePath,
|
||||||
}
|
canonicalFilePath,
|
||||||
};
|
s.hunkContextHashesByFile
|
||||||
addMatchingAliases(s.editedContents);
|
);
|
||||||
addMatchingAliases(s.fileChunkCounts);
|
addMatchingReviewPathAliases(
|
||||||
addMatchingAliases(s.hunkContextHashesByFile);
|
aliases,
|
||||||
addMatchingAliases(s.reviewExternalChangesByFile);
|
filePath,
|
||||||
addMatchingAliases(s.fileContents);
|
canonicalFilePath,
|
||||||
|
s.reviewExternalChangesByFile
|
||||||
|
);
|
||||||
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
|
||||||
|
|
||||||
const nextEdited = { ...s.editedContents };
|
const nextEdited = { ...s.editedContents };
|
||||||
for (const alias of aliases) delete nextEdited[alias];
|
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_GET_DATA_TIMEOUT_MS = 30_000;
|
||||||
const TEAM_FETCH_TIMEOUT_MS = 30_000;
|
const TEAM_FETCH_TIMEOUT_MS = 30_000;
|
||||||
const MEMBER_SPAWN_STATUSES_IPC_RETRY_BACKOFF_MS = 5_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_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 MEMBER_SPAWN_UI_EQUAL_WARN_THROTTLE_MS = 2_000;
|
||||||
const inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
|
const inFlightTeamDataRequests = new Map<string, Promise<TeamViewSnapshot>>();
|
||||||
const inFlightRefreshTeamDataCalls = new Map<string, Set<symbol>>();
|
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 {
|
function noteTeamRefreshBurst(teamName: string): number {
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
|
const diagnostic = teamRefreshBurstDiagnostics.get(teamName) ?? {
|
||||||
|
|
@ -621,77 +575,10 @@ function noteTeamRefreshBurst(teamName: string): number {
|
||||||
|
|
||||||
diagnostic.count += 1;
|
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);
|
teamRefreshBurstDiagnostics.set(teamName, diagnostic);
|
||||||
return diagnostic.count;
|
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(
|
function areLaunchSummaryCountsEqual(
|
||||||
left: PersistedTeamLaunchSummary | undefined,
|
left: PersistedTeamLaunchSummary | undefined,
|
||||||
right: PersistedTeamLaunchSummary | undefined
|
right: PersistedTeamLaunchSummary | undefined
|
||||||
|
|
@ -702,7 +589,13 @@ function areLaunchSummaryCountsEqual(
|
||||||
left.confirmedCount === right.confirmedCount &&
|
left.confirmedCount === right.confirmedCount &&
|
||||||
left.pendingCount === right.pendingCount &&
|
left.pendingCount === right.pendingCount &&
|
||||||
left.failedCount === right.failedCount &&
|
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.launchState === right.launchState &&
|
||||||
left.error === right.error &&
|
left.error === right.error &&
|
||||||
left.hardFailureReason === right.hardFailureReason &&
|
left.hardFailureReason === right.hardFailureReason &&
|
||||||
|
left.skippedForLaunch === right.skippedForLaunch &&
|
||||||
|
left.skipReason === right.skipReason &&
|
||||||
|
left.skippedAt === right.skippedAt &&
|
||||||
left.livenessSource === right.livenessSource &&
|
left.livenessSource === right.livenessSource &&
|
||||||
left.runtimeAlive === right.runtimeAlive &&
|
left.runtimeAlive === right.runtimeAlive &&
|
||||||
left.runtimeModel === right.runtimeModel &&
|
left.runtimeModel === right.runtimeModel &&
|
||||||
|
left.livenessKind === right.livenessKind &&
|
||||||
|
left.runtimeDiagnostic === right.runtimeDiagnostic &&
|
||||||
|
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
|
||||||
left.bootstrapConfirmed === right.bootstrapConfirmed &&
|
left.bootstrapConfirmed === right.bootstrapConfirmed &&
|
||||||
left.hardFailure === right.hardFailure &&
|
left.hardFailure === right.hardFailure &&
|
||||||
leftPendingPermissionIds.length === rightPendingPermissionIds.length &&
|
leftPendingPermissionIds.length === rightPendingPermissionIds.length &&
|
||||||
|
|
@ -802,14 +701,34 @@ function areTeamAgentRuntimeEntriesEqual(
|
||||||
): boolean {
|
): boolean {
|
||||||
if (left === right) return true;
|
if (left === right) return true;
|
||||||
if (!left || !right) return left === right;
|
if (!left || !right) return left === right;
|
||||||
|
const leftDiagnostics = left.diagnostics ?? [];
|
||||||
|
const rightDiagnostics = right.diagnostics ?? [];
|
||||||
return (
|
return (
|
||||||
left.memberName === right.memberName &&
|
left.memberName === right.memberName &&
|
||||||
left.alive === right.alive &&
|
left.alive === right.alive &&
|
||||||
left.restartable === right.restartable &&
|
left.restartable === right.restartable &&
|
||||||
left.backendType === right.backendType &&
|
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.pid === right.pid &&
|
||||||
left.runtimeModel === right.runtimeModel &&
|
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>;
|
) => Promise<TaskComment>;
|
||||||
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
addMember: (teamName: string, request: AddMemberRequest) => Promise<void>;
|
||||||
restartMember: (teamName: string, memberName: string) => Promise<void>;
|
restartMember: (teamName: string, memberName: string) => Promise<void>;
|
||||||
|
skipMemberForLaunch: (teamName: string, memberName: string) => Promise<void>;
|
||||||
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
removeMember: (teamName: string, memberName: string) => Promise<void>;
|
||||||
updateMemberRole: (
|
updateMemberRole: (
|
||||||
teamName: string,
|
teamName: string,
|
||||||
|
|
@ -3211,7 +3131,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
selectTeam: async (teamName: string, opts) => {
|
selectTeam: async (teamName: string, opts) => {
|
||||||
const startedAt = performance.now();
|
|
||||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
const allowReloadWhileProvisioning = opts?.allowReloadWhileProvisioning === true;
|
||||||
// Guard: prevent duplicate in-flight fetches for the same team.
|
// 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)) {
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ipcMs = performance.now() - startedAt;
|
|
||||||
// Stale check: user may have switched to another team during the async call
|
// Stale check: user may have switched to another team during the async call
|
||||||
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
|
if (get().selectedTeamName !== teamName || get().selectedTeamLoadNonce !== requestNonce) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -3276,7 +3194,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
: data;
|
: data;
|
||||||
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
||||||
const setStartedAt = performance.now();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const nextCache =
|
const nextCache =
|
||||||
state.teamDataCacheByName[teamName] === nextTeamData
|
state.teamDataCacheByName[teamName] === nextTeamData
|
||||||
|
|
@ -3295,8 +3212,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||||
const setMs = performance.now() - setStartedAt;
|
|
||||||
const postStartedAt = performance.now();
|
|
||||||
const invalidationState = previousData
|
const invalidationState = previousData
|
||||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||||
: { cacheKeys: [], taskIds: [] };
|
: { cacheKeys: [], taskIds: [] };
|
||||||
|
|
@ -3306,19 +3221,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (invalidationState.taskIds.length > 0) {
|
if (invalidationState.taskIds.length > 0) {
|
||||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
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
|
// Sync tab label with the team's display name from config
|
||||||
const displayName = data.config.name || teamName;
|
const displayName = data.config.name || teamName;
|
||||||
const allTabs = get().getAllPaneTabs();
|
const allTabs = get().getAllPaneTabs();
|
||||||
|
|
@ -3422,19 +3324,15 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
},
|
},
|
||||||
|
|
||||||
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
refreshTeamData: async (teamName: string, opts?: RefreshTeamDataOptions) => {
|
||||||
const startedAt = performance.now();
|
|
||||||
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
const teamStateEpoch = captureTeamLocalStateEpoch(teamName);
|
||||||
const refreshToken = beginInFlightTeamDataRefresh(teamName);
|
const refreshToken = beginInFlightTeamDataRefresh(teamName);
|
||||||
// Silent refresh — update data without showing loading skeleton.
|
// Silent refresh — update data without showing loading skeleton.
|
||||||
// Only selectTeam() sets loading: true (for initial load).
|
// Only selectTeam() sets loading: true (for initial load).
|
||||||
const reusedInFlightRequest =
|
const reusedInFlightRequest =
|
||||||
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
|
opts?.withDedup === true && inFlightTeamDataRequests.has(teamName);
|
||||||
const burstCount = noteTeamRefreshBurst(teamName);
|
noteTeamRefreshBurst(teamName);
|
||||||
if (reusedInFlightRequest) {
|
if (reusedInFlightRequest) {
|
||||||
pendingFreshTeamDataRefreshes.add(teamName);
|
pendingFreshTeamDataRefreshes.add(teamName);
|
||||||
logger.warn(
|
|
||||||
`[perf] refreshTeamData queued-fresh team=${teamName} burst=${burstCount} reason=inFlightDedup`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
const previousData = selectTeamDataForName(get(), teamName);
|
const previousData = selectTeamDataForName(get(), teamName);
|
||||||
|
|
@ -3444,7 +3342,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const ipcMs = performance.now() - startedAt;
|
|
||||||
const projectedTeamData = previousData
|
const projectedTeamData = previousData
|
||||||
? {
|
? {
|
||||||
...data,
|
...data,
|
||||||
|
|
@ -3452,7 +3349,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
}
|
}
|
||||||
: data;
|
: data;
|
||||||
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
const nextTeamData = structurallyShareTeamSnapshot(previousData, projectedTeamData);
|
||||||
const setStartedAt = performance.now();
|
|
||||||
set((state) => {
|
set((state) => {
|
||||||
const nextCache =
|
const nextCache =
|
||||||
state.teamDataCacheByName[teamName] === nextTeamData
|
state.teamDataCacheByName[teamName] === nextTeamData
|
||||||
|
|
@ -3484,8 +3380,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
lastResolvedTeamDataRefreshAtByTeam.set(teamName, Date.now());
|
||||||
const setMs = performance.now() - setStartedAt;
|
|
||||||
const postStartedAt = performance.now();
|
|
||||||
const invalidationState = previousData
|
const invalidationState = previousData
|
||||||
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
? collectTaskChangeInvalidationState(teamName, previousData.tasks, data.tasks)
|
||||||
: { cacheKeys: [], taskIds: [] };
|
: { cacheKeys: [], taskIds: [] };
|
||||||
|
|
@ -3495,20 +3389,6 @@ export const createTeamSlice: StateCreator<AppState, [], [], TeamSlice> = (set,
|
||||||
if (invalidationState.taskIds.length > 0) {
|
if (invalidationState.taskIds.length > 0) {
|
||||||
await api.review.invalidateTaskChangeSummaries(teamName, invalidationState.taskIds);
|
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) {
|
} catch (error) {
|
||||||
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
if (!isTeamLocalStateEpochCurrent(teamName, teamStateEpoch)) {
|
||||||
return;
|
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) => {
|
removeMember: async (teamName: string, memberName: string) => {
|
||||||
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
|
await unwrapIpc('team:removeMember', () => api.teams.removeMember(teamName, memberName));
|
||||||
await get().refreshTeamData(teamName);
|
await get().refreshTeamData(teamName);
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
|
|
||||||
getAvailableTeamProviderModelOptions,
|
getAvailableTeamProviderModelOptions,
|
||||||
getAvailableTeamProviderModels,
|
getAvailableTeamProviderModels,
|
||||||
getTeamModelSelectionError,
|
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([
|
const providerStatus = createCodexProviderStatus([
|
||||||
{
|
{
|
||||||
id: 'gpt-5.5',
|
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({
|
expect(getAvailableTeamProviderModelOptions('codex', providerStatus)[1]).toMatchObject({
|
||||||
value: 'gpt-5.5',
|
value: 'gpt-5.5',
|
||||||
label: '5.5',
|
label: '5.5',
|
||||||
badgeLabel: 'New',
|
badgeLabel: 'New',
|
||||||
availabilityStatus: null,
|
availabilityStatus: 'available',
|
||||||
});
|
});
|
||||||
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toContain(
|
expect(getTeamModelSelectionError('codex', 'gpt-5.5', providerStatus)).toBeNull();
|
||||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps existing disabled model policy on top of the dynamic catalog', () => {
|
it('keeps existing disabled model policy on top of the dynamic catalog', () => {
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@ import type {
|
||||||
MemberSpawnStatus,
|
MemberSpawnStatus,
|
||||||
MemberStatus,
|
MemberStatus,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
|
TeamAgentRuntimeEntry,
|
||||||
TeamProviderId,
|
TeamProviderId,
|
||||||
TeamReviewState,
|
TeamReviewState,
|
||||||
TeamTaskStatus,
|
TeamTaskStatus,
|
||||||
|
|
@ -118,6 +119,7 @@ export const SPAWN_DOT_COLORS: Record<MemberSpawnStatus, string> = {
|
||||||
spawning: 'bg-amber-400',
|
spawning: 'bg-amber-400',
|
||||||
online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]',
|
online: 'bg-emerald-400 animate-[dot-online-jelly_0.45s_ease-out]',
|
||||||
error: 'bg-red-400',
|
error: 'bg-red-400',
|
||||||
|
skipped: 'bg-zinc-500',
|
||||||
};
|
};
|
||||||
|
|
||||||
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||||
|
|
@ -126,6 +128,7 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||||
spawning: 'starting',
|
spawning: 'starting',
|
||||||
online: 'ready',
|
online: 'ready',
|
||||||
error: 'spawn failed',
|
error: 'spawn failed',
|
||||||
|
skipped: 'skipped',
|
||||||
};
|
};
|
||||||
|
|
||||||
function isLaunchStillStarting(
|
function isLaunchStillStarting(
|
||||||
|
|
@ -137,6 +140,9 @@ function isLaunchStillStarting(
|
||||||
if (spawnLaunchState === 'failed_to_start') {
|
if (spawnLaunchState === 'failed_to_start') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
if (spawnLaunchState === 'skipped_for_launch') {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -170,6 +176,9 @@ export function getSpawnAwareDotClass(
|
||||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||||
return SPAWN_DOT_COLORS.error;
|
return SPAWN_DOT_COLORS.error;
|
||||||
}
|
}
|
||||||
|
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||||
|
return SPAWN_DOT_COLORS.skipped;
|
||||||
|
}
|
||||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||||
return 'bg-amber-400 animate-pulse';
|
return 'bg-amber-400 animate-pulse';
|
||||||
}
|
}
|
||||||
|
|
@ -217,6 +226,9 @@ export function getSpawnAwarePresenceLabel(
|
||||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||||
return SPAWN_PRESENCE_LABELS.error;
|
return SPAWN_PRESENCE_LABELS.error;
|
||||||
}
|
}
|
||||||
|
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||||
|
return SPAWN_PRESENCE_LABELS.skipped;
|
||||||
|
}
|
||||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||||
return 'connecting';
|
return 'connecting';
|
||||||
}
|
}
|
||||||
|
|
@ -258,6 +270,9 @@ export function getSpawnCardClass(
|
||||||
) {
|
) {
|
||||||
return 'member-waiting-shimmer';
|
return 'member-waiting-shimmer';
|
||||||
}
|
}
|
||||||
|
if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||||
|
return 'opacity-70';
|
||||||
|
}
|
||||||
if (spawnLaunchState === 'runtime_pending_permission') {
|
if (spawnLaunchState === 'runtime_pending_permission') {
|
||||||
return 'member-waiting-shimmer';
|
return 'member-waiting-shimmer';
|
||||||
}
|
}
|
||||||
|
|
@ -517,6 +532,7 @@ export function getLaunchAwarePresenceLabel(
|
||||||
basePresenceLabel === 'starting' ||
|
basePresenceLabel === 'starting' ||
|
||||||
basePresenceLabel === 'connecting' ||
|
basePresenceLabel === 'connecting' ||
|
||||||
basePresenceLabel === 'spawn failed' ||
|
basePresenceLabel === 'spawn failed' ||
|
||||||
|
basePresenceLabel === 'skipped' ||
|
||||||
basePresenceLabel === 'offline' ||
|
basePresenceLabel === 'offline' ||
|
||||||
basePresenceLabel === 'terminated'
|
basePresenceLabel === 'terminated'
|
||||||
) {
|
) {
|
||||||
|
|
@ -531,8 +547,13 @@ export type MemberLaunchVisualState =
|
||||||
| 'spawning'
|
| 'spawning'
|
||||||
| 'permission_pending'
|
| 'permission_pending'
|
||||||
| 'runtime_pending'
|
| 'runtime_pending'
|
||||||
|
| 'shell_only'
|
||||||
|
| 'runtime_candidate'
|
||||||
|
| 'registered_only'
|
||||||
|
| 'stale_runtime'
|
||||||
| 'settling'
|
| 'settling'
|
||||||
| 'error'
|
| 'error'
|
||||||
|
| 'skipped'
|
||||||
| null;
|
| null;
|
||||||
|
|
||||||
export interface MemberLaunchPresentation {
|
export interface MemberLaunchPresentation {
|
||||||
|
|
@ -556,11 +577,21 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
|
||||||
case 'permission_pending':
|
case 'permission_pending':
|
||||||
return 'awaiting permission';
|
return 'awaiting permission';
|
||||||
case 'runtime_pending':
|
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':
|
case 'settling':
|
||||||
return 'joining team';
|
return 'joining team';
|
||||||
case 'error':
|
case 'error':
|
||||||
return 'failed';
|
return 'failed';
|
||||||
|
case 'skipped':
|
||||||
|
return 'skipped';
|
||||||
default:
|
default:
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
@ -573,6 +604,7 @@ export function buildMemberLaunchPresentation({
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnRuntimeAlive,
|
spawnRuntimeAlive,
|
||||||
runtimeAdvisory,
|
runtimeAdvisory,
|
||||||
|
runtimeEntry,
|
||||||
isLaunchSettling = false,
|
isLaunchSettling = false,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
|
@ -584,6 +616,7 @@ export function buildMemberLaunchPresentation({
|
||||||
spawnLivenessSource: MemberSpawnLivenessSource | undefined;
|
spawnLivenessSource: MemberSpawnLivenessSource | undefined;
|
||||||
spawnRuntimeAlive: boolean | undefined;
|
spawnRuntimeAlive: boolean | undefined;
|
||||||
runtimeAdvisory: MemberRuntimeAdvisory | undefined;
|
runtimeAdvisory: MemberRuntimeAdvisory | undefined;
|
||||||
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
isLaunchSettling?: boolean;
|
isLaunchSettling?: boolean;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
|
@ -628,14 +661,21 @@ export function buildMemberLaunchPresentation({
|
||||||
if (isTeamAlive !== false || isTeamProvisioning) {
|
if (isTeamAlive !== false || isTeamProvisioning) {
|
||||||
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
if (spawnLaunchState === 'failed_to_start' || spawnStatus === 'error') {
|
||||||
launchVisualState = 'error';
|
launchVisualState = 'error';
|
||||||
|
} else if (spawnLaunchState === 'skipped_for_launch' || spawnStatus === 'skipped') {
|
||||||
|
launchVisualState = 'skipped';
|
||||||
} else if (spawnLaunchState === 'runtime_pending_permission') {
|
} else if (spawnLaunchState === 'runtime_pending_permission') {
|
||||||
launchVisualState = 'permission_pending';
|
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 (
|
} else if (
|
||||||
spawnLaunchState === 'runtime_pending_bootstrap' &&
|
runtimeEntry?.livenessKind === 'stale_metadata' ||
|
||||||
spawnStatus === 'online' &&
|
runtimeEntry?.livenessKind === 'not_found'
|
||||||
spawnRuntimeAlive === true
|
|
||||||
) {
|
) {
|
||||||
launchVisualState = 'runtime_pending';
|
launchVisualState = 'stale_runtime';
|
||||||
} else if (
|
} else if (
|
||||||
isLaunchStillStarting(
|
isLaunchStillStarting(
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
|
@ -645,6 +685,12 @@ export function buildMemberLaunchPresentation({
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
|
launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
|
||||||
|
} else if (
|
||||||
|
spawnLaunchState === 'runtime_pending_bootstrap' &&
|
||||||
|
(runtimeEntry?.livenessKind === 'runtime_process' ||
|
||||||
|
(spawnStatus === 'online' && spawnRuntimeAlive === true))
|
||||||
|
) {
|
||||||
|
launchVisualState = 'runtime_pending';
|
||||||
} else if (
|
} else if (
|
||||||
isLaunchSettling &&
|
isLaunchSettling &&
|
||||||
spawnStatus === 'online' &&
|
spawnStatus === 'online' &&
|
||||||
|
|
@ -655,6 +701,19 @@ export function buildMemberLaunchPresentation({
|
||||||
}
|
}
|
||||||
|
|
||||||
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
|
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 =
|
const spawnBadgeLabel =
|
||||||
spawnStatus && spawnStatus !== 'online'
|
spawnStatus && spawnStatus !== 'online'
|
||||||
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
||||||
|
|
@ -663,7 +722,7 @@ export function buildMemberLaunchPresentation({
|
||||||
: null;
|
: null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
presenceLabel,
|
presenceLabel: displayPresenceLabel,
|
||||||
dotClass: runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : dotClass,
|
dotClass: runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : dotClass,
|
||||||
cardClass,
|
cardClass,
|
||||||
runtimeAdvisoryLabel,
|
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(
|
export function resolveMemberRuntimeSummary(
|
||||||
member: ResolvedTeamMember,
|
member: ResolvedTeamMember,
|
||||||
launchParams: TeamLaunchParams | undefined,
|
launchParams: TeamLaunchParams | undefined,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ export function buildPendingRuntimeSummaryCopy(input: {
|
||||||
confirmedCount?: number | null;
|
confirmedCount?: number | null;
|
||||||
expectedMemberCount?: number | null;
|
expectedMemberCount?: number | null;
|
||||||
memberCount?: number | null;
|
memberCount?: number | null;
|
||||||
runtimeAlivePendingCount?: number | null;
|
runtimeProcessPendingCount?: number | null;
|
||||||
includePeriod?: boolean;
|
includePeriod?: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
const pendingCount = input.runtimeAlivePendingCount ?? 0;
|
const pendingCount = input.runtimeProcessPendingCount ?? 0;
|
||||||
if (pendingCount <= 0) {
|
if (pendingCount <= 0) {
|
||||||
return input.includePeriod
|
return input.includePeriod
|
||||||
? 'Last launch is still reconciling.'
|
? 'Last launch is still reconciling.'
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import type {
|
||||||
} from '@shared/types';
|
} from '@shared/types';
|
||||||
|
|
||||||
export {
|
export {
|
||||||
CODEX_DYNAMIC_MODEL_REQUIRES_RUNTIME_SUPPORT_REASON,
|
|
||||||
GPT_5_1_CODEX_MAX_CHATGPT_UI_DISABLED_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_MODEL,
|
||||||
GPT_5_1_CODEX_MINI_UI_DISABLED_REASON,
|
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