fix: harden team runtime liveness
This commit is contained in:
parent
a39ae2fb84
commit
ebb7b5289d
109 changed files with 6244 additions and 1545 deletions
|
|
@ -48,25 +48,19 @@ function normalizeMetaMembers(rawMembers) {
|
|||
}
|
||||
|
||||
function resolveTargetLead(paths, config) {
|
||||
// 1. config.members — agentType check
|
||||
// 1. config.members - canonical lead detection shared with queue routing
|
||||
if (config && config.members && config.members.length) {
|
||||
const lead = config.members.find((m) => m && m.agentType === 'team-lead');
|
||||
const lead = config.members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
|
||||
if (lead && lead.name) return String(lead.name).trim();
|
||||
|
||||
// 2. config.members — name check
|
||||
const namedLead = config.members.find((m) => m && m.name === 'team-lead');
|
||||
if (namedLead && namedLead.name) return String(namedLead.name).trim();
|
||||
}
|
||||
|
||||
// 3. members.meta.json — WITH normalization (trim + dedup)
|
||||
// 2. members.meta.json - WITH normalization (trim + dedup)
|
||||
const metaPath = path.join(paths.teamDir, 'members.meta.json');
|
||||
try {
|
||||
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
||||
const members = normalizeMetaMembers(raw && raw.members);
|
||||
if (members.length > 0) {
|
||||
const metaLead = members.find(
|
||||
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
|
||||
);
|
||||
const metaLead = members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
|
||||
if (metaLead && metaLead.name) return metaLead.name;
|
||||
return members[0].name;
|
||||
}
|
||||
|
|
@ -74,13 +68,8 @@ function resolveTargetLead(paths, config) {
|
|||
/* ENOENT or parse error */
|
||||
}
|
||||
|
||||
// 4. role-based (legacy compat)
|
||||
// 3. First configured member
|
||||
if (config && config.members && config.members.length) {
|
||||
const roleLead = config.members.find(
|
||||
(m) => m && m.role && String(m.role).toLowerCase().includes('lead')
|
||||
);
|
||||
if (roleLead && roleLead.name) return String(roleLead.name).trim();
|
||||
// 5. First member
|
||||
if (config.members[0] && config.members[0].name) return String(config.members[0].name).trim();
|
||||
}
|
||||
|
||||
|
|
@ -141,7 +130,7 @@ function findRecentDuplicate(outboxList, dedupeKey) {
|
|||
function sendCrossTeamMessage(context, flags) {
|
||||
const fromTeam = context.teamName;
|
||||
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
|
||||
const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead';
|
||||
const rawFromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : '';
|
||||
const replyToConversationId =
|
||||
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
|
||||
const conversationId =
|
||||
|
|
@ -156,6 +145,10 @@ function sendCrossTeamMessage(context, flags) {
|
|||
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
|
||||
throw new Error(`Invalid fromTeam: ${fromTeam}`);
|
||||
}
|
||||
const sourceConfig = runtimeHelpers.readTeamConfig(context.paths);
|
||||
if (!sourceConfig || sourceConfig.deletedAt) {
|
||||
throw new Error(`Source team not found: ${fromTeam}`);
|
||||
}
|
||||
if (!TEAM_NAME_PATTERN.test(toTeam)) {
|
||||
throw new Error(`Invalid toTeam: ${toTeam}`);
|
||||
}
|
||||
|
|
@ -165,6 +158,11 @@ function sendCrossTeamMessage(context, flags) {
|
|||
if (!text || text.trim().length === 0) {
|
||||
throw new Error('Message text is required');
|
||||
}
|
||||
const fromMember = rawFromMember
|
||||
? runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFromMember, 'cross-team sender', {
|
||||
allowLeadAliases: true,
|
||||
})
|
||||
: runtimeHelpers.inferLeadName(context.paths);
|
||||
|
||||
// Target context + config
|
||||
const targetContext = createTargetContext(context, toTeam);
|
||||
|
|
|
|||
|
|
@ -101,12 +101,12 @@ function listReviewers(context) {
|
|||
|
||||
function addReviewer(context, reviewer) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
|
||||
const resolvedReviewer = runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
const state = getKanbanState(context);
|
||||
const next = new Set(state.reviewers);
|
||||
next.add(String(reviewer));
|
||||
next.add(String(resolvedReviewer));
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||
...state,
|
||||
reviewers: [...next],
|
||||
|
|
@ -118,7 +118,13 @@ function addReviewer(context, reviewer) {
|
|||
function removeReviewer(context, reviewer) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
const state = getKanbanState(context);
|
||||
const next = state.reviewers.filter((entry) => entry !== reviewer);
|
||||
const resolvedReviewer = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, reviewer, {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
const reviewerNames = new Set(
|
||||
[reviewer, resolvedReviewer].filter((entry) => typeof entry === 'string' && entry.trim())
|
||||
);
|
||||
const next = state.reviewers.filter((entry) => !reviewerNames.has(entry));
|
||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||
...state,
|
||||
reviewers: next,
|
||||
|
|
|
|||
|
|
@ -69,10 +69,9 @@ function normalizeActorKey(value) {
|
|||
function resolveKnownActorName(context, value, label) {
|
||||
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
if (!actor) return null;
|
||||
runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
|
||||
return runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
|
||||
allowLeadAliases: true,
|
||||
});
|
||||
return actor;
|
||||
}
|
||||
|
||||
function tryResolveKnownActorName(context, value, label) {
|
||||
|
|
@ -301,9 +300,10 @@ function requestReview(context, taskId, flags = {}) {
|
|||
}
|
||||
|
||||
const nextFrom =
|
||||
resolveKnownActorName(context, flags.from, 'review requester') || 'team-lead';
|
||||
resolveKnownActorName(context, flags.from, 'review requester') ||
|
||||
resolveKnownActorName(context, 'team-lead', 'review requester');
|
||||
const rawReviewer = getReviewer(context, flags);
|
||||
const nextReviewer = rawReviewer ? (resolveKnownActorName(context, rawReviewer, 'reviewer'), rawReviewer) : null;
|
||||
const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null;
|
||||
const prevReviewState = getEffectiveReviewState(context, currentTask);
|
||||
if (prevReviewState === 'approved') {
|
||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);
|
||||
|
|
|
|||
|
|
@ -80,19 +80,46 @@ function getEffectiveReviewState(task, kanbanEntry) {
|
|||
return historyState;
|
||||
}
|
||||
|
||||
const status = typeof task?.status === 'string' ? task.status.trim() : '';
|
||||
const normalizeFallback = (state, source) => {
|
||||
const normalized = normalizeReviewState(state);
|
||||
if (normalized === 'none') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (status === 'in_progress' || status === 'deleted') {
|
||||
return {
|
||||
state: 'none',
|
||||
source: `${source}_status_reset`,
|
||||
};
|
||||
}
|
||||
|
||||
if (status === 'pending') {
|
||||
return normalized === 'needsFix'
|
||||
? { state: 'needsFix', source: `${source}_pending_needs_fix` }
|
||||
: { state: 'none', source: `${source}_pending_reset` };
|
||||
}
|
||||
|
||||
if (status === 'completed') {
|
||||
return normalized === 'review' || normalized === 'approved'
|
||||
? { state: normalized, source }
|
||||
: { state: 'none', source: `${source}_completed_reset` };
|
||||
}
|
||||
|
||||
return { state: normalized, source };
|
||||
};
|
||||
|
||||
const persisted = normalizeReviewState(task && task.reviewState);
|
||||
if (persisted !== 'none') {
|
||||
return {
|
||||
state: persisted,
|
||||
source: 'task_review_state',
|
||||
};
|
||||
const persistedFallback = normalizeFallback(persisted, 'task_review_state');
|
||||
if (persistedFallback) {
|
||||
return persistedFallback;
|
||||
}
|
||||
|
||||
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
|
||||
return {
|
||||
state: normalizeReviewState(kanbanEntry.column),
|
||||
source: 'kanban_column',
|
||||
};
|
||||
const kanbanFallback = normalizeFallback(kanbanEntry.column, 'kanban_column');
|
||||
if (kanbanFallback) {
|
||||
return kanbanFallback;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ const crypto = require('crypto');
|
|||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||
const LEAD_AGENT_TYPES = new Set(['team-lead', 'lead', 'orchestrator']);
|
||||
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||
'cross_team_send',
|
||||
'cross_team_list_targets',
|
||||
|
|
@ -130,7 +131,7 @@ function isCanonicalLeadMember(member) {
|
|||
const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : '';
|
||||
const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : '';
|
||||
return (
|
||||
agentType === 'team-lead' ||
|
||||
LEAD_AGENT_TYPES.has(agentType) ||
|
||||
name === 'team-lead' ||
|
||||
role === 'team-lead' ||
|
||||
role === 'team lead' ||
|
||||
|
|
@ -175,12 +176,10 @@ function inferLeadName(paths) {
|
|||
const resolved = resolveTeamMembers(paths);
|
||||
const members = resolved.members || [];
|
||||
const lead =
|
||||
members.find(
|
||||
(member) =>
|
||||
member &&
|
||||
typeof member.agentType === 'string' &&
|
||||
member.agentType.trim().toLowerCase() === 'team-lead'
|
||||
) ||
|
||||
members.find((member) => {
|
||||
const agentType = typeof member?.agentType === 'string' ? member.agentType.trim().toLowerCase() : '';
|
||||
return LEAD_AGENT_TYPES.has(agentType);
|
||||
}) ||
|
||||
members.find((member) => String((member && member.name) || '').trim().toLowerCase() === 'team-lead') ||
|
||||
members.find(
|
||||
(member) => {
|
||||
|
|
|
|||
|
|
@ -187,19 +187,23 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
|||
}
|
||||
|
||||
function createTask(context, input) {
|
||||
let taskInput = input;
|
||||
if (input && typeof input.owner === 'string' && input.owner.trim()) {
|
||||
assertKnownTaskActor(context, input.owner, 'task owner');
|
||||
taskInput = {
|
||||
...input,
|
||||
owner: assertKnownTaskActor(context, input.owner, 'task owner'),
|
||||
};
|
||||
}
|
||||
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input));
|
||||
if (input && input.notifyOwner !== false) {
|
||||
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, taskInput));
|
||||
if (taskInput && taskInput.notifyOwner !== false) {
|
||||
maybeNotifyAssignedOwner(context, task, {
|
||||
description: input.description,
|
||||
prompt: input.prompt,
|
||||
description: taskInput.description,
|
||||
prompt: taskInput.prompt,
|
||||
taskRefs: [
|
||||
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
|
||||
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
|
||||
...(Array.isArray(taskInput.descriptionTaskRefs) ? taskInput.descriptionTaskRefs : []),
|
||||
...(Array.isArray(taskInput.promptTaskRefs) ? taskInput.promptTaskRefs : []),
|
||||
],
|
||||
from: input.from,
|
||||
from: taskInput.from,
|
||||
});
|
||||
}
|
||||
return task;
|
||||
|
|
@ -382,6 +386,10 @@ function softDeleteTask(context, taskId, actor) {
|
|||
|
||||
function restoreTask(context, taskId, actor) {
|
||||
return withTeamBoardLock(context.paths, () => {
|
||||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
if (before.status !== 'deleted') {
|
||||
throw new Error(`Task #${before.displayId || before.id} is not deleted; task_restore only restores deleted tasks`);
|
||||
}
|
||||
let task = taskStore.setTaskStatus(context.paths, taskId, 'pending', actor || 'user');
|
||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||
if (hasKanbanReference(state, task.id)) {
|
||||
|
|
@ -403,7 +411,7 @@ function setTaskOwner(context, taskId, owner) {
|
|||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||
const nextOwner = isClearOwnerValue(owner)
|
||||
? owner
|
||||
: (assertKnownTaskActor(context, owner, 'task owner'), owner);
|
||||
: assertKnownTaskActor(context, owner, 'task owner');
|
||||
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
|
||||
return {
|
||||
previousTask: before,
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@ const AGENT_TEAMS_TASK_TOOL_NAMES = [
|
|||
'task_get_comment',
|
||||
'task_link',
|
||||
'task_list',
|
||||
'task_restore',
|
||||
'task_set_clarification',
|
||||
'task_set_owner',
|
||||
'task_set_status',
|
||||
|
|
|
|||
|
|
@ -385,6 +385,31 @@ describe('agent-teams-controller API', () => {
|
|||
expect(briefing).toContain('Counters: actionable=4, awareness=3');
|
||||
});
|
||||
|
||||
it('treats stale legacy terminal reviewState on pending tasks as owner-ready work', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const staleTask = controller.tasks.createTask({
|
||||
subject: 'Legacy stale approved task',
|
||||
owner: 'bob',
|
||||
status: 'pending',
|
||||
reviewState: 'approved',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
const briefing = await controller.tasks.taskBriefing('bob');
|
||||
const staleLine = briefing.split('\n').find((line) => line.includes(`#${staleTask.displayId}`));
|
||||
expect(staleLine).toContain('[status=pending]');
|
||||
expect(staleLine).not.toContain('review=');
|
||||
expect(staleLine).toContain('reason=owner_ready');
|
||||
|
||||
const rows = controller.tasks.listTaskInventory({ owner: 'bob' });
|
||||
expect(rows.find((row) => row.id === staleTask.id)).toMatchObject({
|
||||
status: 'pending',
|
||||
reviewState: 'none',
|
||||
});
|
||||
});
|
||||
|
||||
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -887,7 +912,7 @@ describe('agent-teams-controller API', () => {
|
|||
text: 'Need your decision here.',
|
||||
});
|
||||
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'team-lead.json');
|
||||
const inboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'alice.json');
|
||||
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(rows).toHaveLength(1);
|
||||
expect(rows[0].from).toBe('bob');
|
||||
|
|
@ -1114,6 +1139,82 @@ describe('agent-teams-controller API', () => {
|
|||
expect(leadBriefing).not.toContain(`#${task.displayId}`);
|
||||
});
|
||||
|
||||
it('recognizes lead and orchestrator agent types as canonical team leads', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'my-team',
|
||||
leadSessionId: 'lead-session-1',
|
||||
members: [
|
||||
{ name: 'alice', role: 'developer' },
|
||||
{ name: 'leadbot', agentType: 'lead' },
|
||||
{ name: 'opsbot', agentType: 'orchestrator' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const aliceTask = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' });
|
||||
const leadTask = controller.tasks.createTask({ subject: 'Lead owns this', owner: 'leadbot' });
|
||||
const aliceBriefing = await controller.tasks.taskBriefing('alice');
|
||||
const leadBriefing = await controller.tasks.leadBriefing();
|
||||
|
||||
expect(aliceBriefing).toContain(`#${aliceTask.displayId}`);
|
||||
expect(aliceBriefing).toContain('actionOwner=@alice');
|
||||
expect(aliceBriefing).not.toContain(`#${leadTask.displayId}`);
|
||||
expect(leadBriefing).toContain(`#${leadTask.displayId}`);
|
||||
expect(leadBriefing).not.toContain(`#${aliceTask.displayId}`);
|
||||
});
|
||||
|
||||
it('stores canonical member names for lead aliases in owners, reviewers, and reviewer config', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||
fs.writeFileSync(
|
||||
configPath,
|
||||
JSON.stringify(
|
||||
{
|
||||
name: 'my-team',
|
||||
members: [
|
||||
{ name: 'leadbot', agentType: 'lead' },
|
||||
{ name: 'alice', role: 'reviewer' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' });
|
||||
expect(leadOwnedTask.owner).toBe('leadbot');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
|
||||
const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' });
|
||||
expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot');
|
||||
|
||||
controller.kanban.addReviewer('lead');
|
||||
expect(controller.kanban.listReviewers()).toEqual(['leadbot']);
|
||||
|
||||
const reviewTask = controller.tasks.createTask({ subject: 'Review alias', owner: 'bob' });
|
||||
controller.tasks.completeTask(reviewTask.id, 'bob');
|
||||
controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'lead' });
|
||||
|
||||
const requested = controller.tasks
|
||||
.getTask(reviewTask.id)
|
||||
.historyEvents.filter((event) => event.type === 'review_requested')
|
||||
.at(-1);
|
||||
expect(requested.reviewer).toBe('leadbot');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects task_briefing for unknown members', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
@ -1305,6 +1406,22 @@ describe('agent-teams-controller API', () => {
|
|||
expect(restored.reviewState).toBe('none');
|
||||
});
|
||||
|
||||
it('rejects task_restore for non-deleted tasks', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
|
||||
expect(() => controller.tasks.restoreTask(task.id, 'alice')).toThrow(
|
||||
'task_restore only restores deleted tasks'
|
||||
);
|
||||
expect(controller.tasks.getTask(task.id).status).toBe('completed');
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
|
||||
});
|
||||
|
||||
it('uses actual kanban overlay for kanbanColumn inventory filters', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
|
|
|||
|
|
@ -59,8 +59,8 @@ describe('crossTeam module', () => {
|
|||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||
expect(inbox).toHaveLength(1);
|
||||
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
|
||||
expect(inbox[0].from).toBe('team-a.lead');
|
||||
expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.lead" depth="0"`);
|
||||
expect(inbox[0].from).toBe('team-a.team-lead');
|
||||
expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.team-lead" depth="0"`);
|
||||
expect(inbox[0].conversationId).toBeTruthy();
|
||||
expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`);
|
||||
});
|
||||
|
|
@ -314,6 +314,108 @@ describe('crossTeam module', () => {
|
|||
expect(fs.existsSync(inboxPath)).toBe(true);
|
||||
});
|
||||
|
||||
it('resolves supported lead agent types before tech-lead role text', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [
|
||||
{ name: 'alice', role: 'tech lead' },
|
||||
{ name: 'olivia', agentType: 'lead' },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'olivia.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves orchestrator lead from members.meta.json', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [],
|
||||
},
|
||||
});
|
||||
|
||||
const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json');
|
||||
fs.writeFileSync(
|
||||
metaPath,
|
||||
JSON.stringify({
|
||||
members: [
|
||||
{ name: 'alice', role: 'tech lead' },
|
||||
{ name: 'orla', agentType: 'orchestrator' },
|
||||
],
|
||||
})
|
||||
);
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello',
|
||||
});
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'orla.json'))).toBe(true);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects phantom source teams before delivery or outbox writes', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
text: 'Hello from nowhere',
|
||||
})
|
||||
).toThrow('Source team not found: team-a');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-a'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('rejects unknown cross-team senders', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
name: 'team-a',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
'team-b': {
|
||||
name: 'team-b',
|
||||
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||
},
|
||||
});
|
||||
|
||||
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||
|
||||
expect(() =>
|
||||
controller.crossTeam.sendCrossTeamMessage({
|
||||
toTeam: 'team-b',
|
||||
fromMember: 'alicce',
|
||||
text: 'Hello',
|
||||
})
|
||||
).toThrow('Unknown cross-team sender: alicce');
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('resolves lead by name fallback', () => {
|
||||
const claudeDir = makeClaudeDir({
|
||||
'team-a': {
|
||||
|
|
|
|||
|
|
@ -32,23 +32,23 @@
|
|||
- `mcp-server/src/tools/runtimeTools.ts` уже содержит `runtime_bootstrap_checkin` и `runtime_heartbeat`. Это сильный сигнал, его надо сделать главным источником подтверждения.
|
||||
- `agent-teams-controller/src/internal/runtime.js` уже прокидывает `runtimeBootstrapCheckin()` в desktop runtime.
|
||||
- `src/main/services/team/TeamBootstrapStateReader.ts` уже читает `bootstrap-state.json`, `bootstrap-journal.jsonl` и классифицирует stuck bootstrap. Там уже есть важные тайминги: `ACTIVE_BOOTSTRAP_STUCK_CLASSIFICATION_MS = 3 min` и `TERMINAL_BOOTSTRAP_ONLY_PENDING_GRACE_MS = 5 min`.
|
||||
- `TeamProvisioningService.getLiveTeamAgentRuntimeMetadata()` сейчас собирает evidence из config/meta/persisted runtime/tmux/process table.
|
||||
- Для tmux сейчас читается только `#{pane_id}\t#{pane_pid}` через `listTmuxPanePidsForCurrentPlatform()`. `pane_pid` часто является shell (`zsh`, `bash`, `sh`), поэтому `2 MB` выглядит логично.
|
||||
- `attachLiveRuntimeMetadataToStatuses()` превращает `metadata.alive` в `runtimeAlive: true` и `livenessSource: "process"`.
|
||||
- `reevaluateMemberLaunchStatus()` не fail-ит member после grace timeout, если `runtimeAlive === true`.
|
||||
- `OpenCodeTeamRuntimeAdapter.mapBridgeMemberToRuntimeEvidence()` сейчас может выставить `runtimeAlive: true`, если bridge просто вернул member в состоянии `created` или `permission_blocked`. Это полезный материализационный сигнал, но он слабее реального `runtimePid` и слабее bootstrap.
|
||||
- `TeamProvisioningService.getLiveTeamAgentRuntimeMetadata()` собирает evidence из config/meta/persisted runtime/tmux/process table и прогоняет его через strict resolver.
|
||||
- Для tmux раньше читался только `#{pane_id}\t#{pane_pid}` через `listTmuxPanePidsForCurrentPlatform()`. `pane_pid` часто является shell (`zsh`, `bash`, `sh`), поэтому `2 MB` выглядело логично.
|
||||
- `attachLiveRuntimeMetadataToStatuses()` теперь повышает member до `runtimeAlive: true` только через strong evidence: `confirmed_bootstrap` или `runtime_process`.
|
||||
- `reevaluateMemberLaunchStatus()` больше не доверяет старому `runtimeAlive === true` без live metadata.
|
||||
- `OpenCodeTeamRuntimeAdapter.mapBridgeMemberToRuntimeEvidence()` теперь не выставляет `runtimeAlive: true` для bridge-only `created` или `permission_blocked`. Такие сигналы остаются candidate/pending до bootstrap или OS verification.
|
||||
- `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` уже пишут `confirmed_alive`, `runtimeAlive: true`, `bootstrapConfirmed: true`, `nativeHeartbeat: true` через `updateOpenCodeRuntimeMemberLiveness()`. Значит confirmed state уже есть, надо не дать слабым сигналам выглядеть как он.
|
||||
- `OpenCodeLaunchTransactionStore.canMarkOpenCodeRunReady()` уже требует `member_session_recorded`, `required_tools_proven` и `bootstrap_confirmed`. Это strict readiness precedent, который надо сохранить.
|
||||
- Renderer уже получает оба источника: `memberSpawnStatuses` и `teamAgentRuntimeByTeam`. Но `MemberCard` сейчас получает только `runtimeSummary` строкой, а не сам `TeamAgentRuntimeEntry`.
|
||||
- `teamSlice.areTeamAgentRuntimeEntriesEqual()` сейчас сравнивает только `memberName`, `alive`, `restartable`, `backendType`, `pid`, `runtimeModel`, `rssBytes`. Если добавить `livenessKind`, `pidSource`, `diagnostics`, но не обновить comparator, UI может не перерендериться.
|
||||
- `teamSlice.areMemberSpawnStatusEntriesEqual()` сейчас намеренно игнорирует timing fields и сравнивает только visible spawn fields. Если добавить `livenessKind/runtimeDiagnostic`, comparator тоже надо обновить.
|
||||
- `areLaunchSummaryCountsEqual()` сейчас знает только `confirmedCount`, `pendingCount`, `failedCount`, `runtimeAlivePendingCount`. Новые aggregate diagnostic counts не будут обновлять UI без расширения comparator.
|
||||
- `teamSlice.areTeamAgentRuntimeEntriesEqual()` должен сравнивать `livenessKind`, `pidSource` и diagnostic fields, иначе UI может не перерендериться при смене strict evidence.
|
||||
- `teamSlice.areMemberSpawnStatusEntriesEqual()` должен сравнивать visible liveness fields (`livenessKind/runtimeDiagnostic`) и продолжать игнорировать timing-only fields.
|
||||
- `areLaunchSummaryCountsEqual()` должен сравнивать aggregate diagnostic counts (`shellOnlyPendingCount`, `runtimeProcessPendingCount`, `runtimeCandidatePendingCount`, `noRuntimePendingCount`, `permissionPendingCount`). UI не должен использовать legacy `runtimeAlivePendingCount` как process evidence.
|
||||
- `TeamAgentRuntimeWatcher` обновляет runtime snapshot раз в 5 секунд, а spawn statuses раз в 2.5 секунды. Диагностические поля должны попадать либо в оба snapshot слоя, либо UX должен быть устойчив к задержке runtime snapshot.
|
||||
- Renderer `member-spawn` event сейчас вызывает refresh spawn statuses, но не runtime snapshot. Если tooltip/detail зависят от `TeamAgentRuntimeSnapshot`, event handler тоже должен запланировать runtime refresh.
|
||||
- Runtime tools принимают `metadata`, но `recordOpenCodeRuntimeBootstrapCheckin()` и `recordOpenCodeRuntimeHeartbeat()` сейчас используют только `diagnostics`. Если runtime присылает PID/version/command в `metadata`, эта информация теряется.
|
||||
- `handleMemberSpawnToolResult()` при reason `already_running` сейчас делает `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это нельзя оставлять как strong liveness без проверки актуального runtime identity.
|
||||
- `handleMemberSpawnToolResult()` раньше при reason `already_running` делал `setMemberSpawnStatus(..., "online", ..., "process")`. В strict model это заменено на `waiting` + runtime re-evaluation.
|
||||
- `waitForTmuxPanesToExit()` использует `listTmuxPanePidsForCurrentPlatform()` только как "pane exists" check. Поэтому старый `listPanePids()` wrapper должен остаться ровно pane-existence helper, а не получить новую liveness-семантику.
|
||||
- В проекте уже есть env-mode precedent: `CLAUDE_TEAM_OPENCODE_LAUNCH_MODE` с `dogfood`/`production`/`disabled`. Для liveness rollout лучше использовать такой же явный режим, а не скрытый boolean.
|
||||
- В проекте есть env-mode precedent: `CLAUDE_TEAM_OPENCODE_LAUNCH_MODE` с `dogfood`/`production`/`disabled`. Для member liveness финальное решение другое: strict model включена по умолчанию без отдельного env-флага.
|
||||
- `src/shared/types/api.ts`, `src/preload/index.ts` и `src/renderer/api/httpClient.ts` уже прокидывают `getMemberSpawnStatuses()` и `getTeamAgentRuntime()` через shared snapshot types. Новый контракт можно добавить optional fields без нового IPC channel, но browser HTTP fallback должен возвращать валидный старый shape.
|
||||
- `TeamProvisioningService.readUnixProcessTableRows()` сейчас приватный, sync и читает только `pid,command`. Для надежного liveness нужен `ppid`, WSL-aware execution и unit-test seam. Это не должно оставаться приватным ad hoc helper внутри огромного service.
|
||||
- `getLiveTeamAgentRuntimeMetadata()` сейчас читает tmux panes и process table внутри одного метода. После strict model там станет слишком много правил, поэтому план должен вынести pure resolution в отдельный helper/module.
|
||||
|
|
@ -142,58 +142,21 @@ const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000;
|
|||
|
||||
## Rollout mode
|
||||
|
||||
Строгая модель меняет поведение launch timeout, поэтому ее надо включать контролируемо.
|
||||
Строгая модель меняет поведение launch timeout, поэтому изначальный план рассматривал rollout через отдельный флаг.
|
||||
Текущая реализация после hardening включает strict liveness по умолчанию и не содержит старый переключатель режима.
|
||||
|
||||
Топ 3 rollout вариантов:
|
||||
Актуальное поведение:
|
||||
|
||||
1. Diagnostics-only default, strict behind env flag
|
||||
🎯 9 🛡️ 9 🧠 5 Примерно 80-140 строк.
|
||||
По умолчанию UI получает новые diagnostics, но `runtimeAlive` behavior остается старым. Strict включается через env для dogfood. Это самый безопасный путь для первого PR.
|
||||
| Area | Strict-only behavior |
|
||||
| ------------------------------ | ---------------------------------------- |
|
||||
| `livenessKind` | always filled when evidence exists |
|
||||
| UI labels | enabled |
|
||||
| `runtimeAlive` from shell-only | always false |
|
||||
| `already_running` shortcut | waits for strong runtime verification |
|
||||
| timeout self-heal | strong evidence only |
|
||||
| launchDiagnostics | enabled for warning/error states |
|
||||
|
||||
2. Strict default сразу
|
||||
🎯 6 🛡️ 6 🧠 4 Примерно 40-80 строк.
|
||||
Быстрее исправляет проблему, но риск false negative выше, если реальные teammate processes не содержат ожидаемые identity args.
|
||||
|
||||
3. Полный app setting + env override
|
||||
🎯 8 🛡️ 8 🧠 7 Примерно 180-260 строк.
|
||||
Удобно для пользователей, но это больше surface area: settings UI, persistence, migration, tests. Лучше после dogfood данных.
|
||||
|
||||
Рекомендация: вариант 1.
|
||||
|
||||
Добавить mode resolver рядом с team runtime кодом:
|
||||
|
||||
```ts
|
||||
export type TeamMemberLivenessMode = 'diagnostics' | 'strict';
|
||||
|
||||
export const CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV = 'CLAUDE_TEAM_MEMBER_LIVENESS_MODE';
|
||||
|
||||
export function resolveTeamMemberLivenessModeFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): TeamMemberLivenessMode {
|
||||
const raw = env[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]?.trim().toLowerCase();
|
||||
if (raw === 'strict') return 'strict';
|
||||
return 'diagnostics';
|
||||
}
|
||||
```
|
||||
|
||||
Behavior by mode:
|
||||
|
||||
| Area | `diagnostics` | `strict` |
|
||||
| ------------------------------ | ---------------------------------------- | --------------------------- |
|
||||
| `livenessKind` | filled | filled |
|
||||
| UI labels | enabled | enabled |
|
||||
| `runtimeAlive` from shell-only | old behavior may remain temporarily | always false |
|
||||
| `already_running` shortcut | warning diagnostic, old fallback allowed | must verify strong evidence |
|
||||
| timeout self-heal | old behavior | strong evidence only |
|
||||
| launchDiagnostics | enabled | enabled |
|
||||
|
||||
Important default:
|
||||
|
||||
- In local dogfood, run with `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict`.
|
||||
- Production default can stay `diagnostics` for one release if Phase 0 data is unknown.
|
||||
- After manual scenarios pass, flip default to `strict` and keep env as rollback: `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=diagnostics`.
|
||||
|
||||
This gives an emergency fallback without reverting the UI diagnostics work.
|
||||
Operational rollback должен быть отдельным code revert или follow-up setting, а не скрытым env-флагом.
|
||||
|
||||
## Structured launch diagnostics
|
||||
|
||||
|
|
@ -1251,7 +1214,7 @@ if (
|
|||
|
||||
Файл: `src/main/services/team/TeamProvisioningService.ts`
|
||||
|
||||
`handleMemberSpawnToolResult()` сейчас содержит shortcut:
|
||||
`handleMemberSpawnToolResult()` раньше содержал shortcut:
|
||||
|
||||
```ts
|
||||
if (parsedStatus.reason === 'already_running') {
|
||||
|
|
@ -1261,23 +1224,19 @@ if (parsedStatus.reason === 'already_running') {
|
|||
|
||||
В strict liveness модели это опасно: `already_running` доказывает, что runtime/CLI отказался дублировать spawn, но не доказывает, что нужный teammate сейчас прошел bootstrap или что текущий pane PID является runtime процессом.
|
||||
|
||||
Новая логика:
|
||||
Итоговая логика:
|
||||
|
||||
```ts
|
||||
if (parsedStatus.reason === 'already_running') {
|
||||
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
||||
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
||||
const runtime = await this.findStrongRuntimeEvidenceForMember(run.teamName, spawnedMemberName);
|
||||
if (isStrongRuntimeEvidence(runtime)) {
|
||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
|
||||
} else {
|
||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
||||
this.setMemberRuntimeDiagnostic(run, spawnedMemberName, {
|
||||
livenessKind: runtime?.livenessKind ?? 'registered_only',
|
||||
message: 'Runtime reported already running, but no verified member process was found yet.',
|
||||
severity: 'warning',
|
||||
});
|
||||
}
|
||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
||||
this.appendMemberBootstrapDiagnostic(
|
||||
run,
|
||||
spawnedMemberName,
|
||||
'already_running requires strong runtime verification'
|
||||
);
|
||||
void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
|
||||
}
|
||||
```
|
||||
|
||||
|
|
@ -1825,6 +1784,7 @@ export interface PersistedTeamLaunchSummary {
|
|||
confirmedCount: number;
|
||||
pendingCount: number;
|
||||
failedCount: number;
|
||||
// Compatibility aggregate only. Do not use as process evidence in UI.
|
||||
runtimeAlivePendingCount: number;
|
||||
shellOnlyPendingCount?: number;
|
||||
runtimeProcessPendingCount?: number;
|
||||
|
|
@ -1905,11 +1865,6 @@ Backend/shared:
|
|||
- добавить компактные diagnostic fields в `MemberSpawnStatusEntry`.
|
||||
- добавить bounded `TeamLaunchDiagnosticItem` и `TeamProvisioningProgress.launchDiagnostics`.
|
||||
|
||||
- `src/main/services/team/TeamMemberLivenessMode.ts`
|
||||
- добавить `CLAUDE_TEAM_MEMBER_LIVENESS_MODE`;
|
||||
- добавить resolver `diagnostics`/`strict`;
|
||||
- использовать как dogfood/rollback lever.
|
||||
|
||||
- `src/main/services/team/TeamRuntimeLivenessResolver.ts`
|
||||
- вынести pure liveness classification;
|
||||
- принимать tmux/process/OpenCode/persisted facts;
|
||||
|
|
@ -1928,7 +1883,6 @@ Backend/shared:
|
|||
- расширить `LiveTeamAgentRuntimeMetadata`;
|
||||
- parse sanitized runtime tool `metadata`;
|
||||
- добавить strict evidence helpers;
|
||||
- подключить `TeamMemberLivenessMode`;
|
||||
- использовать `TeamRuntimeLivenessResolver`;
|
||||
- обновить `updateProgress()` extras для `launchDiagnostics`;
|
||||
- переписать tmux/process resolution;
|
||||
|
|
@ -2005,11 +1959,6 @@ Renderer:
|
|||
|
||||
Backend:
|
||||
|
||||
- `TeamMemberLivenessMode.test.ts`
|
||||
- default mode is `diagnostics`;
|
||||
- `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict` enables strict;
|
||||
- unknown values fall back to `diagnostics`.
|
||||
|
||||
- `TeamRuntimeLivenessResolver.test.ts`
|
||||
- tmux foreground shell + no child -> `shell_only`;
|
||||
- verified process row by `--team-name` + `--agent-id` -> `runtime_process`;
|
||||
|
|
@ -2088,9 +2037,9 @@ Renderer:
|
|||
|
||||
Add:
|
||||
|
||||
- `TeamMemberLivenessMode` with default `diagnostics`;
|
||||
- `TeamRuntimeLivenessResolver` pure tests;
|
||||
- process table/tmux providers, but strict behavior disabled by default.
|
||||
- process table/tmux providers;
|
||||
- strict-only runtime evidence flow without a runtime-mode switch.
|
||||
|
||||
Verification:
|
||||
|
||||
|
|
@ -2103,14 +2052,11 @@ pnpm exec vitest run test/main/features/tmux-installer test/main/services/team/T
|
|||
|
||||
🎯 9 🛡️ 9 🧠 7 Примерно 220-320 строк.
|
||||
|
||||
Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only when `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict`. Shell/pane/candidate больше не выставляют `runtimeAlive` в strict mode.
|
||||
|
||||
Keep diagnostics mode as rollback until manual launch scenarios pass.
|
||||
Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only. Shell/pane/candidate больше не выставляют `runtimeAlive`.
|
||||
|
||||
Verification:
|
||||
|
||||
```bash
|
||||
CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict pnpm exec vitest run test/main/services/team/TeamProvisioningService.test.ts
|
||||
pnpm exec vitest run test/main/services/team/TeamProvisioningService.test.ts
|
||||
```
|
||||
|
||||
|
|
@ -2204,9 +2150,9 @@ Scenarios:
|
|||
- `member-spawn` event refreshes runtime snapshot.
|
||||
|
||||
9. Rollout безопасен:
|
||||
- default `diagnostics` mode не меняет hard timeout behavior до включения strict;
|
||||
- `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict` включает strong-only behavior;
|
||||
- `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=diagnostics` работает как rollback без удаления UI diagnostics.
|
||||
- strict behavior включен по умолчанию;
|
||||
- diagnostics UI остается доступным без отдельного mode flag;
|
||||
- rollback требует явного code revert или отдельного follow-up setting.
|
||||
|
||||
10. Provider failures не создают ложный ready:
|
||||
|
||||
|
|
@ -2282,22 +2228,21 @@ Mitigation:
|
|||
## Minimal safe patch order
|
||||
|
||||
1. Добавить типы и optional fields.
|
||||
2. Добавить `TeamMemberLivenessMode` default `diagnostics`.
|
||||
3. Добавить sanitized runtime tool metadata parser.
|
||||
4. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`.
|
||||
5. Добавить process table provider/parser с `ppid`.
|
||||
6. Вынести `TeamRuntimeLivenessResolver`.
|
||||
7. Заполнить `livenessKind` без behavior change.
|
||||
8. Написать backend tests на shell-only, verified runtime, stale event, metadata PID.
|
||||
9. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence behind strict mode.
|
||||
10. Исправить `already_running` shortcut behind strict mode.
|
||||
11. Переключить timeout/self-heal logic behind strict mode.
|
||||
12. Исправить OpenCode bridge mapping.
|
||||
13. Обновить persisted summary diagnostics и store equality.
|
||||
14. Добавить `launchDiagnostics` в progress payload и UI disclosure.
|
||||
15. Добавить renderer labels/tooltips/banner.
|
||||
16. Добавить copy diagnostics.
|
||||
17. После manual validation включить strict default или оставить env rollback на один release.
|
||||
2. Добавить sanitized runtime tool metadata parser.
|
||||
3. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`.
|
||||
4. Добавить process table provider/parser с `ppid`.
|
||||
5. Вынести `TeamRuntimeLivenessResolver`.
|
||||
6. Заполнить `livenessKind`.
|
||||
7. Написать backend tests на shell-only, verified runtime, stale event, metadata PID.
|
||||
8. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence.
|
||||
9. Исправить `already_running` shortcut.
|
||||
10. Переключить timeout/self-heal logic на strong evidence.
|
||||
11. Исправить OpenCode bridge mapping.
|
||||
12. Обновить persisted summary diagnostics и store equality.
|
||||
13. Добавить `launchDiagnostics` в progress payload и UI disclosure.
|
||||
14. Добавить renderer labels/tooltips/banner.
|
||||
15. Добавить copy diagnostics.
|
||||
16. Manual validation: создать команду, проверить pending names, runtime diagnostics и отсутствие false-ready shell-only процесса.
|
||||
|
||||
## Expected UX
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -34,8 +35,9 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
conversationId,
|
||||
replyToConversationId,
|
||||
chainDepth,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
||||
toTeam,
|
||||
|
|
@ -47,7 +49,8 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(chainDepth !== undefined ? { chainDepth } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -57,14 +60,16 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
excludeTeam: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, excludeTeam }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, excludeTeam }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
|
||||
...(excludeTeam ? { excludeTeam } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -73,9 +78,11 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -16,8 +17,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())),
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState()));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -29,10 +32,12 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
column: z.enum(['review', 'approved']),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, column }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, column }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -43,8 +48,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))),
|
||||
execute: async ({ teamName, claudeDir, taskId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId)));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -53,8 +60,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())),
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers()));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -64,8 +73,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
reviewer: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer)));
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -75,9 +86,11 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
reviewer: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, reviewer }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -20,13 +21,16 @@ export function registerLeadTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
|
||||
},
|
||||
],
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
|
|||
import { z } from 'zod';
|
||||
|
||||
import { getController } from '../controller';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
|
||||
const toolContextSchema = {
|
||||
|
|
@ -42,8 +43,9 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
source,
|
||||
leadSessionId,
|
||||
attachments,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).messages.sendMessage({
|
||||
to,
|
||||
|
|
@ -55,6 +57,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(attachments?.length ? { attachments } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -34,8 +35,9 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
port,
|
||||
url,
|
||||
claudeProcessId,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).processes.registerProcess({
|
||||
pid,
|
||||
|
|
@ -47,7 +49,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -57,10 +60,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -71,10 +76,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, pid }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -85,9 +92,11 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
pid: z.number().int().positive(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, pid }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, pid }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent, slimTask } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -20,8 +21,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
reviewer: z.string().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.requestReview(taskId, {
|
||||
|
|
@ -31,7 +33,8 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -42,14 +45,16 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).review.startReview(taskId, {
|
||||
...(from ? { from } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -63,8 +68,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
notifyOwner: z.boolean().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||
|
|
@ -75,7 +81,8 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -88,8 +95,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
comment: z.string().optional(),
|
||||
leadSessionId: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
||||
|
|
@ -99,6 +107,7 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
|||
|
||||
import { getController } from '../controller';
|
||||
import { jsonTextContent } from '../utils/format';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
|
||||
const toolContextSchema = {
|
||||
teamName: z.string().min(1),
|
||||
|
|
@ -57,8 +58,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
worktree,
|
||||
extraCliArgs,
|
||||
waitForReady,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.launchTeam({
|
||||
cwd,
|
||||
...(prompt ? { prompt } : {}),
|
||||
|
|
@ -72,7 +74,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
...(waitForReady !== undefined ? { waitForReady } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -82,14 +85,16 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
waitForStop: z.boolean().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) =>
|
||||
jsonTextContent(
|
||||
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.stopTeam({
|
||||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
...(waitForStop !== undefined ? { waitForStop } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -112,8 +117,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
observedAt,
|
||||
diagnostics,
|
||||
metadata,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({
|
||||
runId,
|
||||
memberName,
|
||||
|
|
@ -124,7 +130,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -156,8 +163,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
createdAt,
|
||||
summary,
|
||||
taskRefs,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({
|
||||
idempotencyKey,
|
||||
runId,
|
||||
|
|
@ -171,7 +179,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -203,8 +212,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
createdAt,
|
||||
summary,
|
||||
metadata,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeTaskEvent({
|
||||
idempotencyKey,
|
||||
runId,
|
||||
|
|
@ -218,7 +228,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -241,8 +252,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
observedAt,
|
||||
status,
|
||||
metadata,
|
||||
}) =>
|
||||
jsonTextContent(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return jsonTextContent(
|
||||
await getController(teamName, claudeDir).runtime.runtimeHeartbeat({
|
||||
runId,
|
||||
memberName,
|
||||
|
|
@ -253,6 +265,7 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...(controlUrl ? { controlUrl } : {}),
|
||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||
})
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,9 +1,8 @@
|
|||
import type { FastMCP } from 'fastmcp';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { agentBlocks, getController } from '../controller';
|
||||
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
|
||||
|
||||
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */
|
||||
|
|
@ -70,42 +69,6 @@ function buildCreateTaskPayload(params: {
|
|||
};
|
||||
}
|
||||
|
||||
function resolveConfigPath(teamName: string, claudeDir?: string): string {
|
||||
const controller = getController(teamName, claudeDir) as {
|
||||
context?: { paths?: { teamDir?: string } };
|
||||
};
|
||||
const teamDir = controller.context?.paths?.teamDir;
|
||||
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
return path.join(teamDir, 'config.json');
|
||||
}
|
||||
|
||||
function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
|
||||
const configPath = resolveConfigPath(teamName, claudeDir);
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||
server.addTool({
|
||||
name: 'task_create',
|
||||
|
|
@ -288,8 +251,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))),
|
||||
execute: async ({ teamName, claudeDir, taskId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -301,12 +268,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
commentId: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, commentId }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId)
|
||||
)
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, commentId }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId))
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -333,8 +300,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
relatedTo,
|
||||
blockedBy,
|
||||
limit,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
getController(teamName, claudeDir).tasks.listTaskInventory({
|
||||
...(owner ? { owner } : {}),
|
||||
|
|
@ -346,7 +314,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
limit: normalizeTaskListLimit(limit),
|
||||
})
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -358,10 +327,42 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
name: 'task_restore',
|
||||
description: 'Restore a deleted task back to pending work state',
|
||||
parameters: z.object({
|
||||
...toolContextSchema,
|
||||
taskId: z.string().min(1),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.restoreTask(taskId, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -372,8 +373,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
await Promise.resolve(jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<string, unknown>))),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -384,10 +396,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
actor: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -398,10 +419,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
owner: z.string().nullable(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, owner }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, owner }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
|
||||
string,
|
||||
unknown
|
||||
>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -413,17 +443,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
text: z.string().min(1),
|
||||
from: z.string().optional(),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, text, from }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, text, from }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
text,
|
||||
...(from ? { from } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -448,20 +480,22 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
filename,
|
||||
mimeType,
|
||||
noFallback,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -488,20 +522,22 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
filename,
|
||||
mimeType,
|
||||
noFallback,
|
||||
}) =>
|
||||
await Promise.resolve(
|
||||
}) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
taskWriteResult(
|
||||
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
file: filePath,
|
||||
...(mode ? { mode } : {}),
|
||||
...(filename ? { filename } : {}),
|
||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||
...(noFallback ? { 'no-fallback': true } : {}),
|
||||
}) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -512,17 +548,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
taskId: z.string().min(1),
|
||||
value: z.enum(['lead', 'user', 'clear']),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, value }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, value }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
||||
taskId,
|
||||
value === 'clear' ? null : value
|
||||
taskId,
|
||||
value === 'clear' ? null : value
|
||||
) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -534,10 +572,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
targetId: z.string().min(1),
|
||||
relationship: relationshipTypeSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
await Promise.resolve(
|
||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship) as Record<string, unknown>))
|
||||
),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.linkTask(
|
||||
taskId,
|
||||
targetId,
|
||||
relationship
|
||||
) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -549,12 +597,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
targetId: z.string().min(1),
|
||||
relationship: relationshipTypeSchema,
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
||||
await Promise.resolve(
|
||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return await Promise.resolve(
|
||||
jsonTextContent(
|
||||
slimTask(getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship) as Record<string, unknown>)
|
||||
slimTask(
|
||||
getController(teamName, claudeDir).tasks.unlinkTask(
|
||||
taskId,
|
||||
targetId,
|
||||
relationship
|
||||
) as Record<string, unknown>
|
||||
)
|
||||
)
|
||||
),
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -565,14 +621,17 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName }) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName),
|
||||
},
|
||||
],
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
|
||||
server.addTool({
|
||||
|
|
@ -582,13 +641,16 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
|||
...toolContextSchema,
|
||||
memberName: z.string().min(1),
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName }) => ({
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
|
||||
},
|
||||
],
|
||||
}),
|
||||
execute: async ({ teamName, claudeDir, memberName }) => {
|
||||
assertConfiguredTeam(teamName, claudeDir);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: 'text' as const,
|
||||
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
|
|
|||
40
mcp-server/src/utils/teamConfig.ts
Normal file
40
mcp-server/src/utils/teamConfig.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
|
||||
import { getController } from '../controller';
|
||||
|
||||
function resolveConfigPath(teamName: string, claudeDir?: string): string {
|
||||
const controller = getController(teamName, claudeDir) as {
|
||||
context?: { paths?: { teamDir?: string } };
|
||||
};
|
||||
const teamDir = controller.context?.paths?.teamDir;
|
||||
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
return path.join(teamDir, 'config.json');
|
||||
}
|
||||
|
||||
export function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
|
||||
const configPath = resolveConfigPath(teamName, claudeDir);
|
||||
let raw = '';
|
||||
try {
|
||||
raw = fs.readFileSync(configPath, 'utf8');
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
||||
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
|
||||
throw new Error('invalid');
|
||||
}
|
||||
} catch {
|
||||
throw new Error(
|
||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
@ -487,6 +487,52 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
}
|
||||
});
|
||||
|
||||
it('fails closed for primary queue and inventory tools when team config is missing over stdio', async () => {
|
||||
const client = new McpStdIoClient(serverPath, workspaceRoot);
|
||||
const expected =
|
||||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.';
|
||||
|
||||
try {
|
||||
await client.initialize();
|
||||
|
||||
const leadBriefing = (await client.callTool(
|
||||
'lead_briefing',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
},
|
||||
40
|
||||
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
|
||||
expect(leadBriefing.result?.isError).toBe(true);
|
||||
expect(leadBriefing.result?.content?.[0]?.text).toContain(expected);
|
||||
|
||||
const taskBriefing = (await client.callTool(
|
||||
'task_briefing',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
memberName: 'alice',
|
||||
},
|
||||
41
|
||||
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
|
||||
expect(taskBriefing.result?.isError).toBe(true);
|
||||
expect(taskBriefing.result?.content?.[0]?.text).toContain(expected);
|
||||
|
||||
const taskList = (await client.callTool(
|
||||
'task_list',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'team-lead',
|
||||
},
|
||||
42
|
||||
)) as { result?: { isError?: boolean; content?: Array<{ text?: string }> } };
|
||||
expect(taskList.result?.isError).toBe(true);
|
||||
expect(taskList.result?.content?.[0]?.text).toContain(expected);
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
});
|
||||
|
||||
it('caps high-volume task_list inventory over stdio and keeps rows compact', async () => {
|
||||
await writeTeamConfig(claudeDir, 'bulk-inventory-team');
|
||||
await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225);
|
||||
|
|
@ -1909,6 +1955,38 @@ describe('agent-teams-mcp stdio e2e', () => {
|
|||
const startDeletedErrorText =
|
||||
startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? '');
|
||||
expect(startDeletedErrorText).toContain('use task_restore before starting work');
|
||||
|
||||
const restoreResult = await client.callTool(
|
||||
'task_restore',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
actor: 'team-lead',
|
||||
},
|
||||
110
|
||||
);
|
||||
const restored = parseJsonToolResult((restoreResult as { result: unknown }).result);
|
||||
expect(restored.status).toBe('pending');
|
||||
expect(restored.reviewState).toBe('none');
|
||||
|
||||
const restoreAgainResult = await client.callTool(
|
||||
'task_restore',
|
||||
{
|
||||
claudeDir,
|
||||
teamName: 'stdio-hardening-team',
|
||||
taskId: task.id,
|
||||
actor: 'team-lead',
|
||||
},
|
||||
111
|
||||
);
|
||||
const restoreAgainResponse = restoreAgainResult as {
|
||||
error?: { message?: string };
|
||||
result?: { content?: Array<{ text?: string }> };
|
||||
};
|
||||
const restoreAgainErrorText =
|
||||
restoreAgainResponse.error?.message ?? (restoreAgainResponse.result?.content?.[0]?.text ?? '');
|
||||
expect(restoreAgainErrorText).toContain('task_restore only restores deleted tasks');
|
||||
} finally {
|
||||
await client.close();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -88,7 +88,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
res.end(JSON.stringify(result.body));
|
||||
} catch (error) {
|
||||
res.writeHead(500, { 'content-type': 'application/json' });
|
||||
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
||||
res.end(
|
||||
JSON.stringify({ error: error instanceof Error ? error.message : String(error) })
|
||||
);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -125,6 +127,10 @@ describe('agent-teams-mcp tools', () => {
|
|||
});
|
||||
|
||||
it('launches and stops teams through the runtime MCP tools', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
|
|
@ -171,6 +177,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
try {
|
||||
const launched = parseJsonToolResult(
|
||||
await getTool('team_launch').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
cwd: '/tmp/project',
|
||||
controlUrl: server.baseUrl,
|
||||
|
|
@ -182,6 +189,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
|
||||
const stopped = parseJsonToolResult(
|
||||
await getTool('team_stop').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
})
|
||||
|
|
@ -216,6 +224,13 @@ describe('agent-teams-mcp tools', () => {
|
|||
});
|
||||
|
||||
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
],
|
||||
});
|
||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||
const server = await startControlServer(async ({ method, url, body }) => {
|
||||
calls.push({ method, url, body });
|
||||
|
|
@ -224,6 +239,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
|
||||
try {
|
||||
await getTool('runtime_bootstrap_checkin').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
runId: 'run-oc',
|
||||
|
|
@ -231,6 +247,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
runtimeSessionId: 'ses-1',
|
||||
});
|
||||
await getTool('runtime_deliver_message').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
idempotencyKey: 'idem-1',
|
||||
|
|
@ -241,6 +258,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
text: 'hello',
|
||||
});
|
||||
await getTool('runtime_task_event').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
idempotencyKey: 'idem-task-1',
|
||||
|
|
@ -251,6 +269,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
event: 'started',
|
||||
});
|
||||
await getTool('runtime_heartbeat').execute({
|
||||
claudeDir,
|
||||
teamName: 'alpha',
|
||||
controlUrl: server.baseUrl,
|
||||
runId: 'run-oc',
|
||||
|
|
@ -280,6 +299,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
|
||||
it('discovers the control endpoint from the published state file', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'alpha', {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
const statePath = path.join(claudeDir, 'team-control-api.json');
|
||||
|
||||
const server = await startControlServer(async ({ method, url }) => {
|
||||
|
|
@ -648,12 +670,16 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(ownerInbox[0].text).toContain('task_start');
|
||||
expect(ownerInbox[0].text).toContain('task_add_comment');
|
||||
expect(ownerInbox[0].text).toContain('Read the plan before starting.');
|
||||
expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.');
|
||||
expect(ownerInbox[0].text).toContain(
|
||||
'If you are idle and this task is ready to start, start it now.'
|
||||
);
|
||||
expect(ownerInbox[0].text).toContain(
|
||||
'If you are busy, blocked, or still need more context, immediately add a short task comment'
|
||||
);
|
||||
expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`);
|
||||
expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.');
|
||||
expect(ownerInbox[3].text).toContain(
|
||||
'If you are idle and this task is ready to start, start it now.'
|
||||
);
|
||||
expect(ownerInbox[3].text).toContain('task_add_comment');
|
||||
|
||||
const briefing = (await getTool('task_briefing').execute({
|
||||
|
|
@ -695,14 +721,22 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(memberBriefingText).toContain(
|
||||
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
|
||||
);
|
||||
expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
|
||||
expect(memberBriefingText).toContain(
|
||||
'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):'
|
||||
);
|
||||
expect(memberBriefingText).toContain('Task briefing for alice:');
|
||||
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
|
||||
|
||||
fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true });
|
||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'carol.json'), '[]');
|
||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]');
|
||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]');
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'),
|
||||
'[]'
|
||||
);
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'),
|
||||
'[]'
|
||||
);
|
||||
|
||||
const inboxResolvedBriefing = (await getTool('member_briefing').execute({
|
||||
claudeDir,
|
||||
|
|
@ -710,7 +744,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
memberName: 'carol',
|
||||
})) as { content: Array<{ text: string }> };
|
||||
const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? '';
|
||||
expect(inboxResolvedBriefingText).toContain('Member briefing for carol on team "gamma" (gamma).');
|
||||
expect(inboxResolvedBriefingText).toContain(
|
||||
'Member briefing for carol on team "gamma" (gamma).'
|
||||
);
|
||||
expect(inboxResolvedBriefingText).toContain('Role: team member.');
|
||||
|
||||
await expect(
|
||||
|
|
@ -897,9 +933,9 @@ describe('agent-teams-mcp tools', () => {
|
|||
teamName,
|
||||
})
|
||||
);
|
||||
expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe(
|
||||
'needsFix'
|
||||
);
|
||||
expect(
|
||||
listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState
|
||||
).toBe('needsFix');
|
||||
|
||||
const kanbanCleared = parseJsonToolResult(
|
||||
await getTool('kanban_clear').execute({
|
||||
|
|
@ -1044,6 +1080,26 @@ describe('agent-teams-mcp tools', () => {
|
|||
);
|
||||
expect(kanbanState.tasks[reviewTask.id]).toBeUndefined();
|
||||
expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id);
|
||||
|
||||
const restored = parseJsonToolResult(
|
||||
await getTool('task_restore').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: reviewTask.id,
|
||||
actor: 'lead',
|
||||
})
|
||||
);
|
||||
expect(restored.status).toBe('pending');
|
||||
expect(restored.reviewState).toBe('none');
|
||||
|
||||
await expect(
|
||||
getTool('task_restore').execute({
|
||||
claudeDir,
|
||||
teamName,
|
||||
taskId: reviewTask.id,
|
||||
actor: 'lead',
|
||||
})
|
||||
).rejects.toThrow('task_restore only restores deleted tasks');
|
||||
});
|
||||
|
||||
it('only notifies the owner on review_approve when notifyOwner is explicit', async () => {
|
||||
|
|
@ -1132,6 +1188,12 @@ describe('agent-teams-mcp tools', () => {
|
|||
it('persists full message metadata through message_send', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const teamName = 'gamma';
|
||||
writeTeamConfig(claudeDir, teamName, {
|
||||
members: [
|
||||
{ name: 'lead', role: 'team-lead' },
|
||||
{ name: 'alice', role: 'developer' },
|
||||
],
|
||||
});
|
||||
|
||||
const sent = parseJsonToolResult(
|
||||
await getTool('message_send').execute({
|
||||
|
|
@ -1155,6 +1217,41 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
||||
});
|
||||
|
||||
it('rejects non-configured teams before MCP side-effect writes', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
writeTeamConfig(claudeDir, 'real-team', {
|
||||
members: [{ name: 'lead', role: 'team-lead' }],
|
||||
});
|
||||
|
||||
await expect(
|
||||
getTool('message_send').execute({
|
||||
claudeDir,
|
||||
teamName: 'typo-team',
|
||||
to: 'alice',
|
||||
text: 'Should not create inbox',
|
||||
})
|
||||
).rejects.toThrow('Unknown team "typo-team"');
|
||||
await expect(
|
||||
getTool('process_register').execute({
|
||||
claudeDir,
|
||||
teamName: 'typo-team',
|
||||
pid: process.pid,
|
||||
label: 'watcher',
|
||||
})
|
||||
).rejects.toThrow('Unknown team "typo-team"');
|
||||
await expect(
|
||||
getTool('cross_team_send').execute({
|
||||
claudeDir,
|
||||
teamName: 'typo-team',
|
||||
toTeam: 'real-team',
|
||||
text: 'Should not deliver',
|
||||
})
|
||||
).rejects.toThrow('Unknown team "typo-team"');
|
||||
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'typo-team'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(claudeDir, 'teams', 'real-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||
});
|
||||
|
||||
it('exposes zod schemas that reject obviously invalid payloads', () => {
|
||||
expect(
|
||||
getTool('task_create').parameters?.safeParse({
|
||||
|
|
@ -1303,9 +1400,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
expect(completed.comments).toBeUndefined();
|
||||
|
||||
// task_list: explicit inventory shape only
|
||||
const listed = parseJsonToolResult(
|
||||
await getTool('task_list').execute({ claudeDir, teamName })
|
||||
);
|
||||
const listed = parseJsonToolResult(await getTool('task_list').execute({ claudeDir, teamName }));
|
||||
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
|
||||
expect(listedTask).toBeDefined();
|
||||
expect(listedTask).toEqual({
|
||||
|
|
@ -1345,9 +1440,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json');
|
||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||
fs.mkdirSync(teamDir, { recursive: true });
|
||||
const existing = fs.existsSync(sentPath)
|
||||
? JSON.parse(fs.readFileSync(sentPath, 'utf8'))
|
||||
: [];
|
||||
const existing = fs.existsSync(sentPath) ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) : [];
|
||||
existing.push(message);
|
||||
fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2));
|
||||
}
|
||||
|
|
@ -1693,9 +1786,7 @@ describe('agent-teams-mcp tools', () => {
|
|||
text: 'Roundtrip test message',
|
||||
timestamp: '2026-03-15T16:00:00.000Z',
|
||||
source: 'user_sent',
|
||||
attachments: [
|
||||
{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 },
|
||||
],
|
||||
attachments: [{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }],
|
||||
});
|
||||
|
||||
const created = parseJsonToolResult(
|
||||
|
|
@ -1813,4 +1904,20 @@ describe('agent-teams-mcp tools', () => {
|
|||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.'
|
||||
);
|
||||
});
|
||||
|
||||
it('fails closed for primary queue and inventory tools when team config does not exist', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const params = { claudeDir, teamName: 'team-lead' };
|
||||
const expected =
|
||||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.';
|
||||
|
||||
await expect(getTool('lead_briefing').execute(params)).rejects.toThrow(expected);
|
||||
await expect(
|
||||
getTool('task_briefing').execute({
|
||||
...params,
|
||||
memberName: 'alice',
|
||||
})
|
||||
).rejects.toThrow(expected);
|
||||
await expect(getTool('task_list').execute(params)).rejects.toThrow(expected);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -13,7 +13,7 @@ import type {
|
|||
} from '../ports/RecentProjectsSourcePort';
|
||||
|
||||
const DEFAULT_CACHE_TTL_MS = 10_000;
|
||||
const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500;
|
||||
const DEFAULT_DEGRADED_CACHE_TTL_MS = 30_000;
|
||||
|
||||
interface SourceLoadResult {
|
||||
candidates: RecentProjectCandidate[];
|
||||
|
|
@ -99,9 +99,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
|||
}
|
||||
|
||||
const viewModel = this.deps.output.present(response);
|
||||
const cacheTtlMs = hasDegradedSources
|
||||
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
|
||||
: this.#cacheTtlMs;
|
||||
const cacheTtlMs = hasDegradedSources ? this.#degradedCacheTtlMs : this.#cacheTtlMs;
|
||||
|
||||
await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
|
||||
this.deps.logger.info('recent-projects loaded', {
|
||||
|
|
|
|||
|
|
@ -16,16 +16,26 @@ import type {
|
|||
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
|
||||
import type { ServiceContext } from '@main/services';
|
||||
|
||||
const CODEX_THREAD_LIMIT = 40;
|
||||
const CODEX_THREAD_LIMIT = 20;
|
||||
const CODEX_INITIALIZE_TIMEOUT_MS = 6_000;
|
||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000;
|
||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000;
|
||||
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
||||
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
|
||||
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||
CODEX_INITIALIZE_TIMEOUT_MS +
|
||||
CODEX_ARCHIVED_FETCH_TIMEOUT_MS +
|
||||
CODEX_LIVE_FETCH_TIMEOUT_MS +
|
||||
CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
|
||||
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
|
||||
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||
const CODEX_STALE_CANDIDATES_TTL_MS = 5 * 60_000;
|
||||
const CODEX_FULL_FAILURE_COOLDOWN_MS = 30_000;
|
||||
|
||||
interface StaleCodexCandidatesSnapshot {
|
||||
candidates: RecentProjectCandidate[];
|
||||
capturedAt: number;
|
||||
}
|
||||
|
||||
function isInteractiveSource(source: unknown): boolean {
|
||||
return source === 'vscode' || source === 'cli';
|
||||
|
|
@ -42,9 +52,24 @@ function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean {
|
|||
return Boolean(result.live.error || result.archived.error);
|
||||
}
|
||||
|
||||
function getFullFailureReason(result: CodexRecentThreadsResult): string | null {
|
||||
if (!result.live.error || !result.archived.error) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (result.live.error === result.archived.error) {
|
||||
return result.live.error;
|
||||
}
|
||||
|
||||
return `live: ${result.live.error}; archived: ${result.archived.error}`;
|
||||
}
|
||||
|
||||
export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
|
||||
readonly sourceId = 'codex';
|
||||
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
|
||||
#staleCandidatesSnapshot: StaleCodexCandidatesSnapshot | null = null;
|
||||
#fullFailureCooldownUntil = 0;
|
||||
#fullFailureCooldownReason: string | null = null;
|
||||
|
||||
constructor(
|
||||
private readonly deps: {
|
||||
|
|
@ -77,8 +102,19 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
};
|
||||
}
|
||||
|
||||
const cooldown = this.#getActiveCooldown();
|
||||
if (cooldown) {
|
||||
this.deps.logger.info('codex recent-projects source cooldown active', cooldown);
|
||||
return {
|
||||
candidates: this.#getFreshStaleCandidates() ?? [],
|
||||
degraded: true,
|
||||
};
|
||||
}
|
||||
|
||||
const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
|
||||
const degraded = isDegradedThreadResult(threadSegments);
|
||||
const fullFailureReason = getFullFailureReason(threadSegments);
|
||||
this.#updateFullFailureCooldown(fullFailureReason);
|
||||
this.#logSegmentFailure(threadSegments, 'live');
|
||||
this.#logSegmentFailure(threadSegments, 'archived');
|
||||
const liveThreads = threadSegments.live.threads;
|
||||
|
|
@ -92,6 +128,25 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
|
||||
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
|
||||
|
||||
if (!degraded) {
|
||||
this.#rememberHealthyCandidates(candidates);
|
||||
}
|
||||
|
||||
if (degraded && candidates.length === 0) {
|
||||
const staleCandidates = this.#getFreshStaleCandidates();
|
||||
if (staleCandidates) {
|
||||
this.deps.logger.info('codex recent-projects served stale candidates', {
|
||||
count: staleCandidates.length,
|
||||
reason: fullFailureReason ?? 'degraded-empty-result',
|
||||
});
|
||||
|
||||
return {
|
||||
candidates: staleCandidates,
|
||||
degraded: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
this.deps.logger.info('codex recent-projects source loaded', {
|
||||
count: candidates.length,
|
||||
degraded,
|
||||
|
|
@ -103,6 +158,53 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
|||
};
|
||||
}
|
||||
|
||||
#getActiveCooldown(): { retryAfterMs: number; reason: string | null } | null {
|
||||
const retryAfterMs = this.#fullFailureCooldownUntil - Date.now();
|
||||
if (retryAfterMs <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
retryAfterMs,
|
||||
reason: this.#fullFailureCooldownReason,
|
||||
};
|
||||
}
|
||||
|
||||
#updateFullFailureCooldown(reason: string | null): void {
|
||||
if (!reason) {
|
||||
this.#fullFailureCooldownUntil = 0;
|
||||
this.#fullFailureCooldownReason = null;
|
||||
return;
|
||||
}
|
||||
|
||||
this.#fullFailureCooldownUntil = Date.now() + CODEX_FULL_FAILURE_COOLDOWN_MS;
|
||||
this.#fullFailureCooldownReason = reason;
|
||||
}
|
||||
|
||||
#rememberHealthyCandidates(candidates: RecentProjectCandidate[]): void {
|
||||
this.#staleCandidatesSnapshot =
|
||||
candidates.length > 0
|
||||
? {
|
||||
candidates,
|
||||
capturedAt: Date.now(),
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
#getFreshStaleCandidates(): RecentProjectCandidate[] | null {
|
||||
const snapshot = this.#staleCandidatesSnapshot;
|
||||
if (!snapshot) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (Date.now() - snapshot.capturedAt > CODEX_STALE_CANDIDATES_TTL_MS) {
|
||||
this.#staleCandidatesSnapshot = null;
|
||||
return null;
|
||||
}
|
||||
|
||||
return [...snapshot.candidates];
|
||||
}
|
||||
|
||||
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
||||
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
|
||||
limit: CODEX_THREAD_LIMIT,
|
||||
|
|
|
|||
|
|
@ -74,10 +74,7 @@ export class CodexAppServerClient {
|
|||
}
|
||||
): Promise<CodexThreadSegmentResult> {
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const initializeTimeoutMs = Math.max(
|
||||
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
||||
requestTimeoutMs
|
||||
);
|
||||
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
||||
const totalTimeoutMs = Math.max(
|
||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||
initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
|
|
@ -122,13 +119,13 @@ export class CodexAppServerClient {
|
|||
const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
||||
const initializeTimeoutMs = Math.max(
|
||||
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
||||
sessionRequestTimeoutMs
|
||||
);
|
||||
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
||||
const totalTimeoutMs = Math.max(
|
||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||
initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
initializeTimeoutMs +
|
||||
liveRequestTimeoutMs +
|
||||
archivedRequestTimeoutMs +
|
||||
MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||
);
|
||||
|
||||
return this.#withThreadListSession(
|
||||
|
|
@ -140,50 +137,55 @@ export class CodexAppServerClient {
|
|||
label: 'codex app-server thread/list',
|
||||
},
|
||||
async (session) => {
|
||||
const [live, archived] = await Promise.allSettled([
|
||||
session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: false,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
liveRequestTimeoutMs
|
||||
),
|
||||
session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: true,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
archivedRequestTimeoutMs
|
||||
),
|
||||
]);
|
||||
const live = await this.#requestThreadListSegment(session, {
|
||||
archived: false,
|
||||
limit: options.limit,
|
||||
timeoutMs: liveRequestTimeoutMs,
|
||||
});
|
||||
const archived = await this.#requestThreadListSegment(session, {
|
||||
archived: true,
|
||||
limit: options.limit,
|
||||
timeoutMs: archivedRequestTimeoutMs,
|
||||
});
|
||||
|
||||
return {
|
||||
live:
|
||||
live.status === 'fulfilled'
|
||||
? { threads: live.value.data ?? [] }
|
||||
: {
|
||||
threads: [],
|
||||
error: live.reason instanceof Error ? live.reason.message : String(live.reason),
|
||||
},
|
||||
archived:
|
||||
archived.status === 'fulfilled'
|
||||
? { threads: archived.value.data ?? [] }
|
||||
: {
|
||||
threads: [],
|
||||
error:
|
||||
archived.reason instanceof Error
|
||||
? archived.reason.message
|
||||
: String(archived.reason),
|
||||
},
|
||||
live,
|
||||
archived,
|
||||
};
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
async #requestThreadListSegment(
|
||||
session: JsonRpcSession,
|
||||
options: {
|
||||
archived: boolean;
|
||||
limit: number;
|
||||
timeoutMs: number;
|
||||
}
|
||||
): Promise<CodexThreadSegmentResult> {
|
||||
try {
|
||||
const response = await session.request<ThreadListResponse>(
|
||||
'thread/list',
|
||||
{
|
||||
archived: options.archived,
|
||||
limit: options.limit,
|
||||
sortKey: 'updated_at',
|
||||
},
|
||||
options.timeoutMs
|
||||
);
|
||||
|
||||
return {
|
||||
threads: response.data ?? [],
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
threads: [],
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
async #withThreadListSession<T>(
|
||||
options: ThreadListSessionOptions,
|
||||
handler: (session: JsonRpcSession) => Promise<T>
|
||||
|
|
|
|||
|
|
@ -25,8 +25,8 @@ import type { TeamSummary } from '@shared/types';
|
|||
|
||||
const INITIAL_RECENT_PROJECTS = 11;
|
||||
const LOAD_MORE_STEP = 8;
|
||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500;
|
||||
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000;
|
||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 30_000;
|
||||
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 120_000;
|
||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3;
|
||||
|
||||
function matchesSearch(project: DashboardRecentProject, query: string): boolean {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import type {
|
|||
} from '@features/recent-projects/contracts';
|
||||
|
||||
const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
|
||||
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 1_500;
|
||||
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 30_000;
|
||||
|
||||
let cachedPayload: DashboardRecentProjectsPayloadLike = null;
|
||||
let cachedAt = 0;
|
||||
|
|
|
|||
|
|
@ -76,6 +76,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
shellOnlyPendingCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
runtimeCandidatePendingCount: 0,
|
||||
noRuntimePendingCount: 0,
|
||||
permissionPendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
||||
});
|
||||
|
|
@ -130,6 +135,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
runtimePid: 333,
|
||||
runtimeSessionId: 'session-bob',
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
pidSource: 'runtime_bootstrap',
|
||||
runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
diagnostics: ['spawn accepted', 'late heartbeat received'],
|
||||
},
|
||||
},
|
||||
|
|
@ -145,12 +155,22 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
runtimePid: 333,
|
||||
runtimeSessionId: 'session-bob',
|
||||
livenessKind: 'confirmed_bootstrap',
|
||||
pidSource: 'runtime_bootstrap',
|
||||
runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted',
|
||||
runtimeDiagnosticSeverity: 'info',
|
||||
});
|
||||
expect(snapshot.summary).toEqual({
|
||||
confirmedCount: 2,
|
||||
pendingCount: 0,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
shellOnlyPendingCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
runtimeCandidatePendingCount: 0,
|
||||
noRuntimePendingCount: 0,
|
||||
permissionPendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('clean_success');
|
||||
});
|
||||
|
|
@ -229,6 +249,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
pendingCount: 0,
|
||||
failedCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
shellOnlyPendingCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
runtimeCandidatePendingCount: 0,
|
||||
noRuntimePendingCount: 0,
|
||||
permissionPendingCount: 0,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('partial_failure');
|
||||
});
|
||||
|
|
@ -279,9 +304,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
evidence: {
|
||||
launchState: 'runtime_pending_permission',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
livenessKind: 'permission_blocked',
|
||||
runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
||||
},
|
||||
},
|
||||
|
|
@ -292,9 +320,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
laneKind: 'secondary',
|
||||
laneOwnerProviderId: 'opencode',
|
||||
launchState: 'runtime_pending_permission',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
agentToolAccepted: true,
|
||||
bootstrapConfirmed: false,
|
||||
livenessKind: 'permission_blocked',
|
||||
runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
||||
hardFailure: false,
|
||||
});
|
||||
|
|
@ -303,7 +334,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
|||
confirmedCount: 1,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
shellOnlyPendingCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
runtimeCandidatePendingCount: 0,
|
||||
noRuntimePendingCount: 0,
|
||||
permissionPendingCount: 1,
|
||||
});
|
||||
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
||||
});
|
||||
|
|
|
|||
|
|
@ -11,6 +11,9 @@ import type {
|
|||
PersistedTeamLaunchPhase,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
ProviderModelLaunchIdentity,
|
||||
TeamAgentRuntimeDiagnosticSeverity,
|
||||
TeamAgentRuntimeLivenessKind,
|
||||
TeamAgentRuntimePidSource,
|
||||
TeamFastMode,
|
||||
TeamProviderBackendId,
|
||||
TeamProviderId,
|
||||
|
|
@ -38,6 +41,12 @@ export interface MixedSecondaryLaneMemberStateInput {
|
|||
hardFailureReason?: string;
|
||||
pendingPermissionRequestIds?: string[];
|
||||
runtimePid?: number;
|
||||
runtimeSessionId?: string;
|
||||
sessionId?: string;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
diagnostics?: string[];
|
||||
} | null;
|
||||
pendingReason?: string;
|
||||
|
|
@ -65,6 +74,19 @@ function deriveMemberLaunchState(params: {
|
|||
return 'starting';
|
||||
}
|
||||
|
||||
function preservesStrongRuntimeAlive(value: {
|
||||
runtimeAlive?: boolean;
|
||||
bootstrapConfirmed?: boolean;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
}): boolean {
|
||||
return (
|
||||
value.runtimeAlive === true &&
|
||||
(value.bootstrapConfirmed === true ||
|
||||
value.livenessKind === 'confirmed_bootstrap' ||
|
||||
value.livenessKind === 'runtime_process')
|
||||
);
|
||||
}
|
||||
|
||||
function buildDiagnostics(
|
||||
member: Pick<
|
||||
PersistedTeamLaunchMemberState,
|
||||
|
|
@ -93,14 +115,17 @@ function buildDiagnostics(
|
|||
}
|
||||
|
||||
function createSourcesFromStatus(
|
||||
status: Pick<MemberSpawnStatusEntry, 'livenessSource' | 'runtimeAlive'>
|
||||
status: Pick<
|
||||
MemberSpawnStatusEntry,
|
||||
'livenessSource' | 'runtimeAlive' | 'bootstrapConfirmed' | 'livenessKind'
|
||||
>
|
||||
): PersistedTeamLaunchMemberSources | undefined {
|
||||
const sources: PersistedTeamLaunchMemberSources = {};
|
||||
if (status.livenessSource === 'heartbeat') {
|
||||
sources.nativeHeartbeat = true;
|
||||
sources.inboxHeartbeat = true;
|
||||
}
|
||||
if (status.livenessSource === 'process' || status.runtimeAlive) {
|
||||
if (status.livenessSource === 'process' && preservesStrongRuntimeAlive(status)) {
|
||||
sources.processAlive = true;
|
||||
}
|
||||
return Object.values(sources).some(Boolean) ? sources : undefined;
|
||||
|
|
@ -119,6 +144,7 @@ function createPrimaryLaneMemberState(params: {
|
|||
const providerId =
|
||||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||
const runtime = params.status;
|
||||
const strongRuntimeAlive = preservesStrongRuntimeAlive(runtime ?? {});
|
||||
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
|
||||
const base: PersistedTeamLaunchMemberState = {
|
||||
name: params.member.name.trim(),
|
||||
|
|
@ -151,21 +177,25 @@ function createPrimaryLaneMemberState(params: {
|
|||
deriveMemberLaunchState({
|
||||
hardFailure: runtime?.hardFailure,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed,
|
||||
runtimeAlive: runtime?.runtimeAlive,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
agentToolAccepted: runtime?.agentToolAccepted,
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
|
||||
}),
|
||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||
runtimeAlive: runtime?.runtimeAlive === true,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||
: undefined,
|
||||
livenessKind: runtime?.livenessKind,
|
||||
runtimeDiagnostic: runtime?.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
|
||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? params.updatedAt : undefined,
|
||||
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
|
||||
lastRuntimeAliveAt: preservesStrongRuntimeAlive(runtime ?? {}) ? params.updatedAt : undefined,
|
||||
lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt,
|
||||
sources,
|
||||
diagnostics: undefined,
|
||||
|
|
@ -180,13 +210,14 @@ function createSecondaryLaneMemberState(
|
|||
const providerId =
|
||||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||
const evidence = params.evidence;
|
||||
const strongRuntimeAlive = preservesStrongRuntimeAlive(evidence ?? {});
|
||||
const hardFailureReason = evidence?.hardFailureReason;
|
||||
const launchState =
|
||||
evidence?.launchState ??
|
||||
deriveMemberLaunchState({
|
||||
hardFailure: evidence?.hardFailure,
|
||||
bootstrapConfirmed: evidence?.bootstrapConfirmed,
|
||||
runtimeAlive: evidence?.runtimeAlive,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
agentToolAccepted: evidence?.agentToolAccepted,
|
||||
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
|
||||
});
|
||||
|
|
@ -214,7 +245,7 @@ function createSecondaryLaneMemberState(
|
|||
laneOwnerProviderId: providerId,
|
||||
launchState,
|
||||
agentToolAccepted: evidence?.agentToolAccepted === true,
|
||||
runtimeAlive: evidence?.runtimeAlive === true,
|
||||
runtimeAlive: strongRuntimeAlive,
|
||||
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
|
||||
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
|
||||
hardFailureReason,
|
||||
|
|
@ -227,15 +258,21 @@ function createSecondaryLaneMemberState(
|
|||
evidence.runtimePid > 0
|
||||
? Math.trunc(evidence.runtimePid)
|
||||
: undefined,
|
||||
runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId,
|
||||
livenessKind: evidence?.livenessKind,
|
||||
pidSource: evidence?.pidSource,
|
||||
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: evidence?.runtimeDiagnosticSeverity,
|
||||
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
||||
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
||||
lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined,
|
||||
runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||
lastRuntimeAliveAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||
lastEvaluatedAt: params.updatedAt,
|
||||
sources: evidence?.runtimeAlive
|
||||
sources: strongRuntimeAlive
|
||||
? {
|
||||
processAlive: true,
|
||||
nativeHeartbeat: evidence.bootstrapConfirmed === true || undefined,
|
||||
inboxHeartbeat: evidence.bootstrapConfirmed === true || undefined,
|
||||
nativeHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
|
||||
inboxHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
|
||||
}
|
||||
: undefined,
|
||||
diagnostics: evidence?.diagnostics?.length
|
||||
|
|
@ -256,6 +293,11 @@ function summarizeMembers(
|
|||
let pendingCount = 0;
|
||||
let failedCount = 0;
|
||||
let runtimeAlivePendingCount = 0;
|
||||
let shellOnlyPendingCount = 0;
|
||||
let runtimeProcessPendingCount = 0;
|
||||
let runtimeCandidatePendingCount = 0;
|
||||
let noRuntimePendingCount = 0;
|
||||
let permissionPendingCount = 0;
|
||||
|
||||
for (const memberName of expectedMembers) {
|
||||
const entry = members[memberName];
|
||||
|
|
@ -275,6 +317,22 @@ function summarizeMembers(
|
|||
if (entry.runtimeAlive) {
|
||||
runtimeAlivePendingCount += 1;
|
||||
}
|
||||
if (entry.launchState === 'runtime_pending_permission') {
|
||||
permissionPendingCount += 1;
|
||||
}
|
||||
if (entry.livenessKind === 'shell_only') {
|
||||
shellOnlyPendingCount += 1;
|
||||
} else if (entry.livenessKind === 'runtime_process') {
|
||||
runtimeProcessPendingCount += 1;
|
||||
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
||||
runtimeCandidatePendingCount += 1;
|
||||
} else if (
|
||||
entry.livenessKind === 'not_found' ||
|
||||
entry.livenessKind === 'stale_metadata' ||
|
||||
entry.livenessKind === 'registered_only'
|
||||
) {
|
||||
noRuntimePendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -282,6 +340,11 @@ function summarizeMembers(
|
|||
pendingCount,
|
||||
failedCount,
|
||||
runtimeAlivePendingCount,
|
||||
shellOnlyPendingCount,
|
||||
runtimeProcessPendingCount,
|
||||
runtimeCandidatePendingCount,
|
||||
noRuntimePendingCount,
|
||||
permissionPendingCount,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,8 +1,8 @@
|
|||
import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter';
|
||||
import {
|
||||
TmuxPlatformCommandExecutor,
|
||||
type RuntimeProcessTableRow,
|
||||
type TmuxPaneRuntimeInfo,
|
||||
TmuxPlatformCommandExecutor,
|
||||
} from '../infrastructure/runtime/TmuxPlatformCommandExecutor';
|
||||
|
||||
const runtimeStatusSource = new TmuxStatusSourceAdapter();
|
||||
|
|
|
|||
|
|
@ -17,3 +17,4 @@ export type {
|
|||
RuntimeProcessTableRow,
|
||||
TmuxPaneRuntimeInfo,
|
||||
} from './infrastructure/runtime/TmuxPlatformCommandExecutor';
|
||||
export { parseRuntimeProcessTable } from './infrastructure/runtime/TmuxPlatformCommandExecutor';
|
||||
|
|
|
|||
|
|
@ -96,4 +96,26 @@ describe('TmuxPlatformCommandExecutor', () => {
|
|||
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();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -17,8 +17,6 @@
|
|||
process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||
|
||||
// Keep userData stable before any integration can initialize Electron storage.
|
||||
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
||||
|
||||
// Sentry must stay near the top to capture early errors after storage migration.
|
||||
import './sentry';
|
||||
|
||||
|
|
@ -142,6 +140,7 @@ import {
|
|||
markRendererUnavailable,
|
||||
safeSendToRenderer,
|
||||
} from './utils/safeWebContentsSend';
|
||||
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
||||
import { syncTelemetryFlag } from './sentry';
|
||||
import {
|
||||
ActiveTeamRegistry,
|
||||
|
|
|
|||
|
|
@ -570,6 +570,7 @@ export class ConfigManager {
|
|||
...DEFAULT_CONFIG.general,
|
||||
...(loaded.general ?? {}),
|
||||
};
|
||||
mergedGeneral.multimodelEnabled = true;
|
||||
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
|
||||
|
||||
// Merge triggers: preserve existing triggers, add missing builtin ones
|
||||
|
|
|
|||
|
|
@ -1,4 +1,3 @@
|
|||
import { once } from 'node:events';
|
||||
import readline from 'node:readline';
|
||||
|
||||
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||
|
|
@ -49,6 +48,9 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
|
|||
|
||||
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
||||
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
||||
const DEFAULT_STDIN_CLOSE_TIMEOUT_MS = 250;
|
||||
const DEFAULT_CLOSE_TIMEOUT_MS = 1_000;
|
||||
const DEFAULT_FORCE_CLOSE_TIMEOUT_MS = 1_000;
|
||||
|
||||
export class JsonRpcRequestError extends Error {
|
||||
readonly code: number | null;
|
||||
|
|
@ -76,6 +78,9 @@ export class JsonRpcStdioClient {
|
|||
env?: NodeJS.ProcessEnv;
|
||||
requestTimeoutMs?: number;
|
||||
totalTimeoutMs?: number;
|
||||
stdinCloseTimeoutMs?: number;
|
||||
closeTimeoutMs?: number;
|
||||
forceCloseTimeoutMs?: number;
|
||||
label: string;
|
||||
},
|
||||
handler: (session: JsonRpcSession) => Promise<T>
|
||||
|
|
@ -95,8 +100,14 @@ export class JsonRpcStdioClient {
|
|||
args: string[];
|
||||
env?: NodeJS.ProcessEnv;
|
||||
requestTimeoutMs?: number;
|
||||
stdinCloseTimeoutMs?: number;
|
||||
closeTimeoutMs?: number;
|
||||
forceCloseTimeoutMs?: number;
|
||||
}): Promise<JsonRpcSession> {
|
||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||
const stdinCloseTimeoutMs = options.stdinCloseTimeoutMs ?? DEFAULT_STDIN_CLOSE_TIMEOUT_MS;
|
||||
const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
|
||||
const forceCloseTimeoutMs = options.forceCloseTimeoutMs ?? DEFAULT_FORCE_CLOSE_TIMEOUT_MS;
|
||||
const child = spawnCli(options.binaryPath, options.args, {
|
||||
env: options.env,
|
||||
stdio: ['pipe', 'pipe', 'pipe'],
|
||||
|
|
@ -194,6 +205,58 @@ export class JsonRpcStdioClient {
|
|||
);
|
||||
});
|
||||
|
||||
const waitForChildClose = (timeoutMs: number): Promise<boolean> => {
|
||||
if (child.exitCode !== null || child.signalCode !== null) {
|
||||
return Promise.resolve(true);
|
||||
}
|
||||
|
||||
return new Promise<boolean>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (closedByEvent: boolean): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
child.off('close', onClose);
|
||||
resolve(closedByEvent);
|
||||
};
|
||||
const onClose = (): void => finish(true);
|
||||
const timeoutId = setTimeout(() => finish(false), timeoutMs);
|
||||
timeoutId.unref?.();
|
||||
|
||||
child.once('close', onClose);
|
||||
});
|
||||
};
|
||||
|
||||
const closeStdin = async (): Promise<void> => {
|
||||
if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded) {
|
||||
return;
|
||||
}
|
||||
|
||||
await new Promise<void>((resolve) => {
|
||||
let settled = false;
|
||||
const finish = (): void => {
|
||||
if (settled) {
|
||||
return;
|
||||
}
|
||||
|
||||
settled = true;
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
};
|
||||
const timeoutId = setTimeout(finish, stdinCloseTimeoutMs);
|
||||
timeoutId.unref?.();
|
||||
|
||||
try {
|
||||
child.stdin!.end(() => finish());
|
||||
} catch {
|
||||
finish();
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
const close = async (): Promise<void> => {
|
||||
if (closed) {
|
||||
return;
|
||||
|
|
@ -204,21 +267,26 @@ export class JsonRpcStdioClient {
|
|||
notificationListeners.clear();
|
||||
lineReader.close();
|
||||
|
||||
if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) {
|
||||
await new Promise<void>((resolve) => {
|
||||
try {
|
||||
child.stdin!.end(() => resolve());
|
||||
} catch {
|
||||
resolve();
|
||||
}
|
||||
});
|
||||
await closeStdin();
|
||||
|
||||
const gracefulClose = waitForChildClose(closeTimeoutMs);
|
||||
killProcessTree(child, 'SIGTERM');
|
||||
if (await gracefulClose) {
|
||||
return;
|
||||
}
|
||||
|
||||
killProcessTree(child);
|
||||
try {
|
||||
await once(child, 'close');
|
||||
} catch {
|
||||
this.logger.warn('json-rpc close wait failed');
|
||||
this.logger.warn('json-rpc close timed out; force killing process', {
|
||||
pid: child.pid,
|
||||
timeoutMs: closeTimeoutMs,
|
||||
});
|
||||
|
||||
const forcedClose = waitForChildClose(forceCloseTimeoutMs);
|
||||
killProcessTree(child, 'SIGKILL');
|
||||
if (!(await forcedClose)) {
|
||||
this.logger.warn('json-rpc force close timed out', {
|
||||
pid: child.pid,
|
||||
timeoutMs: forceCloseTimeoutMs,
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@ import fs from 'node:fs';
|
|||
import os from 'node:os';
|
||||
import path from 'node:path';
|
||||
|
||||
import { afterEach, describe, expect, it } from 'vitest';
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { JsonRpcStdioClient } from '../JsonRpcStdioClient';
|
||||
|
||||
|
|
@ -42,6 +42,35 @@ rl.on('line', (line) => {
|
|||
return scriptPath;
|
||||
}
|
||||
|
||||
function createSignalIgnoringJsonRpcServerScript(): string {
|
||||
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-rpc-stdio-client-'));
|
||||
tempDirs.push(tempDir);
|
||||
const scriptPath = path.join(tempDir, 'server.cjs');
|
||||
fs.writeFileSync(
|
||||
scriptPath,
|
||||
`
|
||||
const readline = require('node:readline');
|
||||
process.on('SIGTERM', () => {});
|
||||
setInterval(() => {}, 10_000);
|
||||
|
||||
const rl = readline.createInterface({ input: process.stdin });
|
||||
rl.on('line', (line) => {
|
||||
const message = JSON.parse(line);
|
||||
if (message.jsonrpc !== '2.0') {
|
||||
return;
|
||||
}
|
||||
process.stdout.write(JSON.stringify({
|
||||
jsonrpc: '2.0',
|
||||
id: message.id,
|
||||
result: { ok: true },
|
||||
}) + '\\n');
|
||||
});
|
||||
`,
|
||||
'utf8'
|
||||
);
|
||||
return scriptPath;
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
for (const dir of tempDirs.splice(0)) {
|
||||
fs.rmSync(dir, { recursive: true, force: true });
|
||||
|
|
@ -76,4 +105,30 @@ describe('JsonRpcStdioClient', () => {
|
|||
}
|
||||
);
|
||||
});
|
||||
|
||||
it('force kills the child when session close does not finish gracefully', async () => {
|
||||
const scriptPath = createSignalIgnoringJsonRpcServerScript();
|
||||
const warn = vi.fn();
|
||||
const client = new JsonRpcStdioClient({ warn });
|
||||
|
||||
await client.withSession(
|
||||
{
|
||||
binaryPath: process.execPath,
|
||||
args: [scriptPath],
|
||||
label: 'stubborn json-rpc close',
|
||||
requestTimeoutMs: 1_000,
|
||||
totalTimeoutMs: 2_000,
|
||||
closeTimeoutMs: 25,
|
||||
forceCloseTimeoutMs: 1_000,
|
||||
},
|
||||
async (session) => {
|
||||
await expect(session.request('ping')).resolves.toEqual({ ok: true });
|
||||
}
|
||||
);
|
||||
|
||||
expect(warn).toHaveBeenCalledWith('json-rpc close timed out; force killing process', {
|
||||
pid: expect.any(Number),
|
||||
timeoutMs: 25,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -337,10 +337,11 @@ function normalizeBootstrapMemberState(
|
|||
const status = typeof raw.status === 'string' ? raw.status : 'pending';
|
||||
const hardFailure = status === 'failed';
|
||||
const bootstrapConfirmed = status === 'bootstrap_confirmed';
|
||||
const runtimeAlive = bootstrapConfirmed || status === 'runtime_alive';
|
||||
const bootstrapReportedRuntimeAlive = status === 'runtime_alive';
|
||||
const runtimeAlive = bootstrapConfirmed;
|
||||
const agentToolAccepted =
|
||||
bootstrapConfirmed ||
|
||||
runtimeAlive ||
|
||||
bootstrapReportedRuntimeAlive ||
|
||||
status === 'registered' ||
|
||||
status === 'spawn_started' ||
|
||||
hardFailure;
|
||||
|
|
@ -351,7 +352,7 @@ function normalizeBootstrapMemberState(
|
|||
? 'failed_to_start'
|
||||
: bootstrapConfirmed
|
||||
? 'confirmed_alive'
|
||||
: runtimeAlive || agentToolAccepted
|
||||
: agentToolAccepted
|
||||
? 'runtime_pending_bootstrap'
|
||||
: 'starting',
|
||||
agentToolAccepted,
|
||||
|
|
@ -381,13 +382,13 @@ function normalizeBootstrapMemberState(
|
|||
? raw.failureReason.trim()
|
||||
: 'deterministic bootstrap failed',
|
||||
]
|
||||
: runtimeAlive
|
||||
? bootstrapConfirmed
|
||||
? ['late heartbeat received']
|
||||
: ['runtime alive', 'waiting for teammate check-in']
|
||||
: agentToolAccepted
|
||||
? ['spawn accepted']
|
||||
: undefined,
|
||||
: bootstrapConfirmed
|
||||
? ['late heartbeat received']
|
||||
: bootstrapReportedRuntimeAlive
|
||||
? ['runtime alive reported by bootstrap state', 'waiting for strict live verification']
|
||||
: agentToolAccepted
|
||||
? ['spawn accepted']
|
||||
: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
|
|||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { createLogger } from '@shared/utils/logger';
|
||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
||||
import { getKanbanColumnFromReviewState, getReviewStateFromTask } from '@shared/utils/reviewState';
|
||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||
|
|
@ -265,6 +265,11 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null {
|
|||
return body.length > 0 ? body : null;
|
||||
}
|
||||
|
||||
function isExplicitLeadRole(role: string | undefined): boolean {
|
||||
const normalized = role?.trim().toLowerCase();
|
||||
return normalized === 'lead' || normalized === 'team lead' || normalized === 'team-lead';
|
||||
}
|
||||
|
||||
function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
|
||||
return members.some((member) => {
|
||||
if (isLeadMember(member)) {
|
||||
|
|
@ -274,7 +279,7 @@ function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
|
|||
if (normalizedName === 'lead') {
|
||||
return true;
|
||||
}
|
||||
return member.role?.toLowerCase().includes('lead') === true;
|
||||
return isExplicitLeadRole(member.role);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -287,7 +292,7 @@ function hasExplicitLeadInConfig(config: TeamConfig): boolean {
|
|||
if (normalizedName === 'lead') {
|
||||
return true;
|
||||
}
|
||||
return member.role?.toLowerCase().includes('lead') === true;
|
||||
return isExplicitLeadRole(member.role);
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -530,16 +535,22 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
private resolveTaskReviewState(
|
||||
task: Pick<TeamTask, 'reviewState'>
|
||||
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
|
||||
kanbanTaskState?: KanbanState['tasks'][string]
|
||||
): 'none' | 'review' | 'needsFix' | 'approved' {
|
||||
return normalizeReviewState(task.reviewState);
|
||||
return getReviewStateFromTask({
|
||||
historyEvents: task.historyEvents,
|
||||
reviewState: task.reviewState,
|
||||
status: task.status,
|
||||
kanbanColumn: kanbanTaskState?.column,
|
||||
});
|
||||
}
|
||||
|
||||
private attachKanbanCompatibility(
|
||||
task: TeamTask,
|
||||
kanbanTaskState?: KanbanState['tasks'][string]
|
||||
): TeamTaskWithKanban {
|
||||
const reviewState = this.resolveTaskReviewState(task);
|
||||
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
||||
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
||||
return {
|
||||
...task,
|
||||
|
|
@ -557,8 +568,15 @@ export class TeamDataService {
|
|||
private resolveReviewerFromHistory(
|
||||
task: TeamTask,
|
||||
kanbanTaskState?: KanbanState['tasks'][string],
|
||||
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(task)
|
||||
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(
|
||||
task,
|
||||
kanbanTaskState
|
||||
)
|
||||
): string | null {
|
||||
if (reviewState !== 'review') {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (task.historyEvents?.length) {
|
||||
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
|
||||
const event = task.historyEvents[i];
|
||||
|
|
@ -571,7 +589,10 @@ export class TeamDataService {
|
|||
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
|
||||
break;
|
||||
}
|
||||
if (event.type === 'status_changed' && event.to === 'in_progress') {
|
||||
if (
|
||||
event.type === 'status_changed' &&
|
||||
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')
|
||||
) {
|
||||
break;
|
||||
}
|
||||
if (event.type === 'task_created') {
|
||||
|
|
@ -894,7 +915,8 @@ export class TeamDataService {
|
|||
continue;
|
||||
}
|
||||
const info = teamInfoMap.get(task.teamName)!;
|
||||
const reviewState = this.resolveTaskReviewState(task);
|
||||
const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id];
|
||||
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
||||
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
|
||||
|
||||
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
||||
|
|
@ -2156,7 +2178,11 @@ export class TeamDataService {
|
|||
|
||||
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
||||
if (!config) return 'team-lead';
|
||||
const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead'));
|
||||
const members = config.members ?? [];
|
||||
const lead =
|
||||
members.find((member) => isLeadMember(member)) ??
|
||||
members.find((member) => member.name?.trim().toLowerCase() === 'lead') ??
|
||||
members.find((member) => isExplicitLeadRole(member.role));
|
||||
return lead?.name ?? config.members?.[0]?.name ?? 'team-lead';
|
||||
}
|
||||
|
||||
|
|
@ -2729,9 +2755,9 @@ export class TeamDataService {
|
|||
}
|
||||
|
||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
this.getController(teamName).review.requestReview(taskId, {
|
||||
from: 'user',
|
||||
from: leadName,
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
}
|
||||
|
|
@ -3194,15 +3220,15 @@ export class TeamDataService {
|
|||
|
||||
if (patch.op === 'set_column') {
|
||||
if (patch.column === 'review') {
|
||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
controller.review.requestReview(taskId, {
|
||||
from: 'user',
|
||||
from: leadName,
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
});
|
||||
} else {
|
||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
controller.review.approveReview(taskId, {
|
||||
from: 'user',
|
||||
from: leadName,
|
||||
suppressTaskComment: true,
|
||||
'notify-owner': true,
|
||||
...(leadSessionId ? { leadSessionId } : {}),
|
||||
|
|
@ -3211,9 +3237,9 @@ export class TeamDataService {
|
|||
return;
|
||||
}
|
||||
|
||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||
controller.review.requestChanges(taskId, {
|
||||
from: 'user',
|
||||
from: leadName,
|
||||
comment: patch.comment?.trim() || 'Reviewer requested changes.',
|
||||
...(patch.op === 'request_changes' && patch.taskRefs?.length
|
||||
? { taskRefs: patch.taskRefs }
|
||||
|
|
|
|||
|
|
@ -80,6 +80,23 @@ function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | u
|
|||
: 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' ||
|
||||
|
|
@ -181,7 +198,7 @@ export function summarizePersistedLaunchMembers(
|
|||
continue;
|
||||
}
|
||||
pendingCount += 1;
|
||||
if (entry.runtimeAlive) {
|
||||
if (preservesStrongRuntimeAlive(entry)) {
|
||||
runtimeAlivePendingCount += 1;
|
||||
}
|
||||
if (entry.launchState === 'runtime_pending_permission') {
|
||||
|
|
@ -193,7 +210,11 @@ export function summarizePersistedLaunchMembers(
|
|||
runtimeProcessPendingCount += 1;
|
||||
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
||||
runtimeCandidatePendingCount += 1;
|
||||
} else if (entry.livenessKind === 'not_found' || entry.livenessKind === 'stale_metadata') {
|
||||
} else if (
|
||||
entry.livenessKind === 'not_found' ||
|
||||
entry.livenessKind === 'stale_metadata' ||
|
||||
entry.livenessKind === 'registered_only'
|
||||
) {
|
||||
noRuntimePendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -369,6 +390,18 @@ function normalizePersistedMemberState(
|
|||
return null;
|
||||
}
|
||||
const providerId = normalizeOptionalTeamProviderId(parsed.providerId);
|
||||
const bootstrapConfirmed =
|
||||
toBoolean(parsed.bootstrapConfirmed) || parsed.launchState === 'confirmed_alive';
|
||||
const livenessKind = normalizeLivenessKind(parsed.livenessKind);
|
||||
const runtimeAlive = preservesStrongRuntimeAlive({
|
||||
runtimeAlive: toBoolean(parsed.runtimeAlive),
|
||||
bootstrapConfirmed,
|
||||
livenessKind,
|
||||
});
|
||||
const sources = normalizeSources(parsed.sources) ?? {};
|
||||
if (!runtimeAlive) {
|
||||
sources.processAlive = undefined;
|
||||
}
|
||||
const next: PersistedTeamLaunchMemberState = {
|
||||
name: normalizedName,
|
||||
providerId,
|
||||
|
|
@ -399,8 +432,8 @@ function normalizePersistedMemberState(
|
|||
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
|
||||
launchState: 'starting',
|
||||
agentToolAccepted: toBoolean(parsed.agentToolAccepted),
|
||||
runtimeAlive: toBoolean(parsed.runtimeAlive),
|
||||
bootstrapConfirmed: toBoolean(parsed.bootstrapConfirmed),
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed,
|
||||
hardFailure: toBoolean(parsed.hardFailure),
|
||||
hardFailureReason:
|
||||
typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
||||
|
|
@ -411,7 +444,7 @@ function normalizePersistedMemberState(
|
|||
),
|
||||
runtimePid: normalizeRuntimePid(parsed.runtimePid),
|
||||
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
|
||||
livenessKind: normalizeLivenessKind(parsed.livenessKind),
|
||||
livenessKind,
|
||||
pidSource: normalizePidSource(parsed.pidSource),
|
||||
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
|
||||
runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity),
|
||||
|
|
@ -424,7 +457,7 @@ function normalizePersistedMemberState(
|
|||
typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined,
|
||||
lastEvaluatedAt:
|
||||
typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback,
|
||||
sources: normalizeSources(parsed.sources),
|
||||
sources: Object.values(sources).some(Boolean) ? sources : undefined,
|
||||
diagnostics: Array.isArray(parsed.diagnostics)
|
||||
? parsed.diagnostics.filter(
|
||||
(item): item is string => typeof item === 'string' && item.trim().length > 0
|
||||
|
|
@ -554,14 +587,15 @@ export function snapshotFromRuntimeMemberStatuses(params: {
|
|||
sources.nativeHeartbeat = true;
|
||||
sources.inboxHeartbeat = true;
|
||||
}
|
||||
if (runtime?.livenessSource === 'process' || runtime?.runtimeAlive) {
|
||||
const runtimeAlive = preservesStrongRuntimeAlive(runtime);
|
||||
if (runtime?.livenessSource === 'process' && runtimeAlive) {
|
||||
sources.processAlive = true;
|
||||
}
|
||||
const entry: PersistedTeamLaunchMemberState = {
|
||||
name,
|
||||
launchState: runtime?.launchState ?? 'starting',
|
||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||
runtimeAlive: runtime?.runtimeAlive === true,
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||
|
|
@ -574,7 +608,7 @@ export function snapshotFromRuntimeMemberStatuses(params: {
|
|||
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
|
||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined,
|
||||
lastRuntimeAliveAt: runtimeAlive ? updatedAt : undefined,
|
||||
lastEvaluatedAt: runtime?.updatedAt ?? updatedAt,
|
||||
sources: Object.values(sources).some(Boolean) ? sources : undefined,
|
||||
diagnostics: undefined,
|
||||
|
|
@ -610,6 +644,7 @@ export function snapshotToMemberSpawnStatuses(
|
|||
if (!entry) continue;
|
||||
let status: MemberSpawnStatusEntry['status'] = 'offline';
|
||||
let livenessSource: MemberSpawnLivenessSource | undefined;
|
||||
const runtimeAlive = preservesStrongRuntimeAlive(entry);
|
||||
if (entry.launchState === 'failed_to_start') {
|
||||
status = 'error';
|
||||
} else if (entry.launchState === 'confirmed_alive') {
|
||||
|
|
@ -619,8 +654,8 @@ export function snapshotToMemberSpawnStatuses(
|
|||
entry.launchState === 'runtime_pending_permission' ||
|
||||
entry.launchState === 'runtime_pending_bootstrap'
|
||||
) {
|
||||
status = entry.runtimeAlive ? 'online' : 'waiting';
|
||||
livenessSource = entry.runtimeAlive ? 'process' : undefined;
|
||||
status = runtimeAlive ? 'online' : 'waiting';
|
||||
livenessSource = runtimeAlive ? 'process' : undefined;
|
||||
} else {
|
||||
status = entry.agentToolAccepted ? 'waiting' : 'spawning';
|
||||
}
|
||||
|
|
@ -631,7 +666,7 @@ export function snapshotToMemberSpawnStatuses(
|
|||
hardFailureReason: entry.hardFailureReason,
|
||||
livenessSource,
|
||||
agentToolAccepted: entry.agentToolAccepted,
|
||||
runtimeAlive: entry.runtimeAlive,
|
||||
runtimeAlive,
|
||||
bootstrapConfirmed: entry.bootstrapConfirmed,
|
||||
hardFailure: entry.hardFailure,
|
||||
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
||||
|
|
|
|||
|
|
@ -1,14 +0,0 @@
|
|||
export type TeamMemberLivenessMode = 'diagnostics' | 'strict';
|
||||
|
||||
export const CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV = 'CLAUDE_TEAM_MEMBER_LIVENESS_MODE';
|
||||
|
||||
export function resolveTeamMemberLivenessModeFromEnv(
|
||||
env: NodeJS.ProcessEnv = process.env
|
||||
): TeamMemberLivenessMode {
|
||||
const raw = env[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]?.trim().toLowerCase();
|
||||
return raw === 'strict' ? 'strict' : 'diagnostics';
|
||||
}
|
||||
|
||||
export function isStrictTeamMemberLivenessMode(env: NodeJS.ProcessEnv = process.env): boolean {
|
||||
return resolveTeamMemberLivenessModeFromEnv(env) === 'strict';
|
||||
}
|
||||
|
|
@ -177,14 +177,14 @@ import {
|
|||
} from './TeamLaunchStateEvaluator';
|
||||
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
||||
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
||||
import {
|
||||
isStrongRuntimeEvidence,
|
||||
resolveTeamMemberRuntimeLiveness,
|
||||
} from './TeamRuntimeLivenessResolver';
|
||||
import { isStrictTeamMemberLivenessMode } from './TeamMemberLivenessMode';
|
||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||
import { TeamMetaStore } from './TeamMetaStore';
|
||||
import {
|
||||
isStrongRuntimeEvidence,
|
||||
resolveTeamMemberRuntimeLiveness,
|
||||
sanitizeProcessCommandForDiagnostics,
|
||||
} from './TeamRuntimeLivenessResolver';
|
||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
import { TeamTaskReader } from './TeamTaskReader';
|
||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||
|
|
@ -425,6 +425,54 @@ function parseRuntimeToolMetadata(value: unknown): RuntimeToolMetadata {
|
|||
};
|
||||
}
|
||||
|
||||
function mentionsProcessTableUnavailable(value: string | undefined): boolean {
|
||||
return /\bprocess table\b.*\bunavailable\b/i.test(value ?? '');
|
||||
}
|
||||
|
||||
function buildRuntimeToolMetadataDiagnostics(metadata: RuntimeToolMetadata | undefined): string[] {
|
||||
if (!metadata) {
|
||||
return [];
|
||||
}
|
||||
const diagnostics: string[] = [];
|
||||
if (metadata.runtimePid != null) {
|
||||
diagnostics.push(`runtime pid: ${metadata.runtimePid}`);
|
||||
}
|
||||
if (metadata.processCommand) {
|
||||
const processCommand = sanitizeProcessCommandForDiagnostics(metadata.processCommand);
|
||||
if (processCommand) {
|
||||
diagnostics.push(`runtime process command: ${processCommand}`);
|
||||
}
|
||||
}
|
||||
if (metadata.runtimeVersion) {
|
||||
diagnostics.push(`runtime version: ${metadata.runtimeVersion}`);
|
||||
}
|
||||
if (metadata.hostPid != null) {
|
||||
diagnostics.push(`runtime host pid: ${metadata.hostPid}`);
|
||||
}
|
||||
if (metadata.cwd) {
|
||||
diagnostics.push(`runtime cwd: ${metadata.cwd}`);
|
||||
}
|
||||
return diagnostics;
|
||||
}
|
||||
|
||||
function buildRuntimeDiagnosticForSpawn(
|
||||
metadata: LiveTeamAgentRuntimeMetadata
|
||||
): string | undefined {
|
||||
const baseDiagnostic = metadata.runtimeDiagnostic;
|
||||
const processTableUnavailable =
|
||||
mentionsProcessTableUnavailable(baseDiagnostic) ||
|
||||
metadata.diagnostics?.some((diagnostic) => mentionsProcessTableUnavailable(diagnostic));
|
||||
if (!processTableUnavailable) {
|
||||
return baseDiagnostic;
|
||||
}
|
||||
if (mentionsProcessTableUnavailable(baseDiagnostic)) {
|
||||
return baseDiagnostic;
|
||||
}
|
||||
return baseDiagnostic
|
||||
? `${baseDiagnostic}; process table unavailable`
|
||||
: 'process table unavailable';
|
||||
}
|
||||
|
||||
function runtimeTaskRefs(teamName: string, value: unknown): InboxMessage['taskRefs'] | undefined {
|
||||
const refs = normalizeRuntimeStringArray(value);
|
||||
return refs.length > 0
|
||||
|
|
@ -515,6 +563,32 @@ const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
|||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||
const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 2;
|
||||
const OPENCODE_PROJECT_EVIDENCE_MISSING_DIAGNOSTIC =
|
||||
'OpenCode production E2E evidence artifact has no entry for the current working directory';
|
||||
const OPENCODE_PROJECT_EVIDENCE_NOTE =
|
||||
'OpenCode has not been verified on this project yet. This does not mean the selected models are broken.';
|
||||
|
||||
function pushUniqueProvisioningWarning(warnings: string[], warning: string): void {
|
||||
if (!warnings.includes(warning)) {
|
||||
warnings.push(warning);
|
||||
}
|
||||
}
|
||||
|
||||
function isOpenCodeProjectEvidenceMissingDiagnostic(value: string): boolean {
|
||||
return value.trim() === OPENCODE_PROJECT_EVIDENCE_MISSING_DIAGNOSTIC;
|
||||
}
|
||||
|
||||
function isOpenCodeProjectEvidenceMissingPrepareFailure(
|
||||
prepare: TeamRuntimePrepareResult
|
||||
): prepare is TeamRuntimePrepareResult & { ok: false } {
|
||||
if (prepare.ok || prepare.reason !== 'e2e_missing') {
|
||||
return false;
|
||||
}
|
||||
const diagnostics = prepare.diagnostics
|
||||
.map((diagnostic) => diagnostic.trim())
|
||||
.filter((diagnostic) => diagnostic.length > 0);
|
||||
return diagnostics.length > 0 && diagnostics.every(isOpenCodeProjectEvidenceMissingDiagnostic);
|
||||
}
|
||||
|
||||
function applyDistinctProvisioningMemberColors<
|
||||
T extends { name: string; color?: string; removedAt?: number },
|
||||
|
|
@ -1537,7 +1611,11 @@ function summarizeMemberSpawnStatusRecord(
|
|||
runtimeProcessPendingCount += 1;
|
||||
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
||||
runtimeCandidatePendingCount += 1;
|
||||
} else if (entry.livenessKind === 'not_found' || entry.livenessKind === 'stale_metadata') {
|
||||
} else if (
|
||||
entry.livenessKind === 'not_found' ||
|
||||
entry.livenessKind === 'stale_metadata' ||
|
||||
entry.livenessKind === 'registered_only'
|
||||
) {
|
||||
noRuntimePendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -3245,12 +3323,13 @@ function updateProgress(
|
|||
function buildLaunchDiagnosticsFromRun(
|
||||
run: ProvisioningRun
|
||||
): TeamLaunchDiagnosticItem[] | undefined {
|
||||
if (!run.isLaunch || run.memberSpawnStatuses.size === 0) {
|
||||
const memberSpawnStatuses = run.memberSpawnStatuses;
|
||||
if (!run.isLaunch || !memberSpawnStatuses || memberSpawnStatuses.size === 0) {
|
||||
return undefined;
|
||||
}
|
||||
const observedAt = nowIso();
|
||||
const items: TeamLaunchDiagnosticItem[] = [];
|
||||
for (const [memberName, entry] of run.memberSpawnStatuses.entries()) {
|
||||
for (const [memberName, entry] of memberSpawnStatuses.entries()) {
|
||||
if (entry.launchState === 'confirmed_alive') {
|
||||
items.push({
|
||||
id: `${memberName}:bootstrap_confirmed`,
|
||||
|
|
@ -3286,6 +3365,18 @@ function buildLaunchDiagnosticsFromRun(
|
|||
});
|
||||
continue;
|
||||
}
|
||||
if (mentionsProcessTableUnavailable(entry.runtimeDiagnostic)) {
|
||||
items.push({
|
||||
id: `${memberName}:process_table_unavailable`,
|
||||
memberName,
|
||||
severity: 'warning',
|
||||
code: 'process_table_unavailable',
|
||||
label: `${memberName} - process table unavailable`,
|
||||
detail: entry.runtimeDiagnostic,
|
||||
observedAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.livenessKind === 'shell_only') {
|
||||
items.push({
|
||||
id: `${memberName}:tmux_shell_only`,
|
||||
|
|
@ -3322,6 +3413,22 @@ function buildLaunchDiagnosticsFromRun(
|
|||
});
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
entry.livenessKind === 'registered_only' ||
|
||||
entry.livenessKind === 'stale_metadata' ||
|
||||
entry.livenessKind === 'not_found'
|
||||
) {
|
||||
items.push({
|
||||
id: `${memberName}:runtime_not_found`,
|
||||
memberName,
|
||||
severity: 'warning',
|
||||
code: 'runtime_not_found',
|
||||
label: `${memberName} - no runtime found`,
|
||||
detail: entry.runtimeDiagnostic,
|
||||
observedAt,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
if (entry.agentToolAccepted) {
|
||||
items.push({
|
||||
id: `${memberName}:spawn_accepted`,
|
||||
|
|
@ -3679,7 +3786,12 @@ export class TeamProvisioningService {
|
|||
private readonly runtimeAdapterProgressByRunId = new Map<string, TeamProvisioningProgress>();
|
||||
private readonly runtimeAdapterRunByTeam = new Map<
|
||||
string,
|
||||
{ runId: string; providerId: TeamProviderId; cwd?: string }
|
||||
{
|
||||
runId: string;
|
||||
providerId: TeamProviderId;
|
||||
cwd?: string;
|
||||
members?: Record<string, TeamRuntimeMemberLaunchEvidence>;
|
||||
}
|
||||
>();
|
||||
private readonly cancelledRuntimeAdapterRunIds = new Set<string>();
|
||||
private stopAllTeamsGeneration = 0;
|
||||
|
|
@ -5882,7 +5994,10 @@ export class TeamProvisioningService {
|
|||
},
|
||||
diagnostics: mergeRuntimeDiagnostics(
|
||||
previousMember?.diagnostics,
|
||||
input.diagnostics,
|
||||
[
|
||||
...normalizeRuntimeStringArray(input.diagnostics),
|
||||
...buildRuntimeToolMetadataDiagnostics(input.metadata),
|
||||
],
|
||||
input.reason
|
||||
),
|
||||
};
|
||||
|
|
@ -6498,17 +6613,13 @@ export class TeamProvisioningService {
|
|||
}
|
||||
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
||||
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
||||
if (isStrictTeamMemberLivenessMode()) {
|
||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
||||
this.appendMemberBootstrapDiagnostic(
|
||||
run,
|
||||
spawnedMemberName,
|
||||
'already_running requires strong runtime verification'
|
||||
);
|
||||
void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
|
||||
return;
|
||||
}
|
||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
|
||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
||||
this.appendMemberBootstrapDiagnostic(
|
||||
run,
|
||||
spawnedMemberName,
|
||||
'already_running requires strong runtime verification'
|
||||
);
|
||||
void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
|
||||
} else {
|
||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
||||
}
|
||||
|
|
@ -6924,7 +7035,6 @@ export class TeamProvisioningService {
|
|||
}
|
||||
|
||||
const updatedAt = nowIso();
|
||||
const strictLiveness = isStrictTeamMemberLivenessMode();
|
||||
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
||||
const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
|
||||
|
||||
|
|
@ -7066,14 +7176,9 @@ export class TeamProvisioningService {
|
|||
: isSharedOpenCodeHost
|
||||
? false
|
||||
: backendType !== 'in-process';
|
||||
const launchSnapshotAlive =
|
||||
this.isTeamAlive(teamName) &&
|
||||
(strictLiveness
|
||||
? launchMember?.bootstrapConfirmed === true ||
|
||||
launchMember?.launchState === 'confirmed_alive'
|
||||
: launchMember?.runtimeAlive === true ||
|
||||
launchMember?.bootstrapConfirmed === true ||
|
||||
launchMember?.launchState === 'confirmed_alive');
|
||||
const historicalBootstrapConfirmed =
|
||||
launchMember?.bootstrapConfirmed === true ||
|
||||
launchMember?.launchState === 'confirmed_alive';
|
||||
let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined;
|
||||
if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
|
||||
try {
|
||||
|
|
@ -7089,7 +7194,7 @@ export class TeamProvisioningService {
|
|||
|
||||
snapshotMembers[memberName] = {
|
||||
memberName,
|
||||
alive: liveRuntimeMember?.alive === true || launchSnapshotAlive,
|
||||
alive: liveRuntimeMember?.alive === true,
|
||||
restartable,
|
||||
...(backendType ? { backendType } : {}),
|
||||
...(memberProviderId ? { providerId: memberProviderId } : {}),
|
||||
|
|
@ -7120,6 +7225,7 @@ export class TeamProvisioningService {
|
|||
...(liveRuntimeMember?.runtimeLastSeenAt
|
||||
? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt }
|
||||
: {}),
|
||||
...(historicalBootstrapConfirmed ? { historicalBootstrapConfirmed: true } : {}),
|
||||
...(liveRuntimeMember?.runtimeDiagnostic
|
||||
? { runtimeDiagnostic: liveRuntimeMember.runtimeDiagnostic }
|
||||
: {}),
|
||||
|
|
@ -7593,8 +7699,7 @@ export class TeamProvisioningService {
|
|||
if (!refreshed) return;
|
||||
if (
|
||||
refreshed.launchState === 'failed_to_start' ||
|
||||
refreshed.launchState === 'confirmed_alive' ||
|
||||
refreshed.runtimeAlive
|
||||
refreshed.launchState === 'confirmed_alive'
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -7602,81 +7707,92 @@ export class TeamProvisioningService {
|
|||
if (!refreshedFirstSpawnAcceptedAt) {
|
||||
return;
|
||||
}
|
||||
if (isStrictTeamMemberLivenessMode()) {
|
||||
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName);
|
||||
const metadata =
|
||||
runtimeByMember.get(memberName) ??
|
||||
[...runtimeByMember.entries()].find(([candidateName]) =>
|
||||
matchesObservedMemberNameForExpected(candidateName, memberName)
|
||||
)?.[1];
|
||||
const acceptedAtMs = Date.parse(refreshedFirstSpawnAcceptedAt);
|
||||
const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity;
|
||||
const runtimeDiagnostic = metadata?.runtimeDiagnostic;
|
||||
if (metadata?.livenessKind === 'runtime_process') {
|
||||
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
|
||||
return;
|
||||
}
|
||||
if (metadata?.livenessKind === 'permission_blocked') {
|
||||
const next = {
|
||||
const restartPending = run.pendingMemberRestarts.has(memberName);
|
||||
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName);
|
||||
const metadata =
|
||||
runtimeByMember.get(memberName) ??
|
||||
[...runtimeByMember.entries()].find(([candidateName]) =>
|
||||
matchesObservedMemberNameForExpected(candidateName, memberName)
|
||||
)?.[1];
|
||||
const acceptedAtMs = Date.parse(refreshedFirstSpawnAcceptedAt);
|
||||
const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity;
|
||||
const runtimeDiagnostic = metadata?.runtimeDiagnostic;
|
||||
if (metadata?.livenessKind === 'runtime_process') {
|
||||
if (elapsedMs >= MEMBER_BOOTSTRAP_STALL_MS) {
|
||||
run.memberSpawnStatuses.set(memberName, {
|
||||
...refreshed,
|
||||
livenessKind: metadata.livenessKind,
|
||||
runtimeDiagnostic: runtimeDiagnostic ?? 'waiting for permission approval',
|
||||
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
|
||||
runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
livenessLastCheckedAt: nowIso(),
|
||||
launchState: 'runtime_pending_permission' as const,
|
||||
};
|
||||
run.memberSpawnStatuses.set(memberName, next);
|
||||
this.emitMemberSpawnChange(run, memberName);
|
||||
return;
|
||||
});
|
||||
}
|
||||
if (
|
||||
metadata?.livenessKind === 'runtime_process_candidate' &&
|
||||
elapsedMs < MEMBER_BOOTSTRAP_STALL_MS
|
||||
) {
|
||||
const next = {
|
||||
...refreshed,
|
||||
livenessKind: metadata.livenessKind,
|
||||
runtimeDiagnostic: runtimeDiagnostic ?? 'runtime process candidate detected',
|
||||
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
|
||||
livenessLastCheckedAt: nowIso(),
|
||||
};
|
||||
run.memberSpawnStatuses.set(memberName, next);
|
||||
this.emitMemberSpawnChange(run, memberName);
|
||||
const stallDelayMs = Math.max(
|
||||
1_000,
|
||||
Date.parse(refreshedFirstSpawnAcceptedAt) + MEMBER_BOOTSTRAP_STALL_MS - Date.now()
|
||||
);
|
||||
const stallKey = `${this.getMemberLaunchGraceKey(run, memberName)}:bootstrap-stall`;
|
||||
if (!this.pendingTimeouts.has(stallKey)) {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingTimeouts.delete(stallKey);
|
||||
void this.reevaluateMemberLaunchStatus(run, memberName);
|
||||
}, stallDelayMs);
|
||||
timer.unref?.();
|
||||
this.pendingTimeouts.set(stallKey, timer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const strictReason =
|
||||
runtimeDiagnostic ??
|
||||
(metadata?.livenessKind === 'shell_only'
|
||||
? 'Tmux pane is alive, but no teammate runtime process was found.'
|
||||
: 'Teammate did not join within the launch grace window.');
|
||||
this.setMemberSpawnStatus(run, memberName, 'error', strictReason);
|
||||
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
|
||||
return;
|
||||
}
|
||||
const restartPending = run.pendingMemberRestarts.has(memberName);
|
||||
if (metadata?.livenessKind === 'permission_blocked') {
|
||||
const next = {
|
||||
...refreshed,
|
||||
livenessKind: metadata.livenessKind,
|
||||
runtimeDiagnostic: runtimeDiagnostic ?? 'waiting for permission approval',
|
||||
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
|
||||
livenessLastCheckedAt: nowIso(),
|
||||
launchState: 'runtime_pending_permission' as const,
|
||||
};
|
||||
run.memberSpawnStatuses.set(memberName, next);
|
||||
this.emitMemberSpawnChange(run, memberName);
|
||||
return;
|
||||
}
|
||||
if (
|
||||
metadata?.livenessKind === 'runtime_process_candidate' &&
|
||||
elapsedMs < MEMBER_BOOTSTRAP_STALL_MS
|
||||
) {
|
||||
const next = {
|
||||
...refreshed,
|
||||
livenessKind: metadata.livenessKind,
|
||||
runtimeDiagnostic: runtimeDiagnostic ?? 'runtime process candidate detected',
|
||||
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
|
||||
livenessLastCheckedAt: nowIso(),
|
||||
};
|
||||
run.memberSpawnStatuses.set(memberName, next);
|
||||
this.emitMemberSpawnChange(run, memberName);
|
||||
const stallDelayMs = Math.max(
|
||||
1_000,
|
||||
Date.parse(refreshedFirstSpawnAcceptedAt) + MEMBER_BOOTSTRAP_STALL_MS - Date.now()
|
||||
);
|
||||
const stallKey = `${this.getMemberLaunchGraceKey(run, memberName)}:bootstrap-stall`;
|
||||
if (!this.pendingTimeouts.has(stallKey)) {
|
||||
const timer = setTimeout(() => {
|
||||
this.pendingTimeouts.delete(stallKey);
|
||||
void this.reevaluateMemberLaunchStatus(run, memberName);
|
||||
}, stallDelayMs);
|
||||
timer.unref?.();
|
||||
this.pendingTimeouts.set(stallKey, timer);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const strictReason = restartPending
|
||||
? buildRestartGraceTimeoutReason(memberName)
|
||||
: (runtimeDiagnostic ??
|
||||
(metadata?.livenessKind === 'shell_only'
|
||||
? 'Tmux pane is alive, but no teammate runtime process was found.'
|
||||
: 'Teammate did not join within the launch grace window.'));
|
||||
if (restartPending) {
|
||||
run.pendingMemberRestarts.delete(memberName);
|
||||
}
|
||||
this.setMemberSpawnStatus(
|
||||
run,
|
||||
memberName,
|
||||
'error',
|
||||
restartPending
|
||||
? buildRestartGraceTimeoutReason(memberName)
|
||||
: 'Teammate did not join within the launch grace window.'
|
||||
);
|
||||
run.memberSpawnStatuses.set(memberName, {
|
||||
...refreshed,
|
||||
runtimeAlive: false,
|
||||
livenessSource: undefined,
|
||||
bootstrapConfirmed: false,
|
||||
...(metadata?.livenessKind ? { livenessKind: metadata.livenessKind } : {}),
|
||||
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||
...(metadata?.runtimeDiagnosticSeverity
|
||||
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
|
||||
: {}),
|
||||
livenessLastCheckedAt: nowIso(),
|
||||
});
|
||||
this.setMemberSpawnStatus(run, memberName, 'error', strictReason);
|
||||
}
|
||||
|
||||
private shouldSkipMemberSpawnAudit(run: ProvisioningRun): boolean {
|
||||
|
|
@ -10152,6 +10268,7 @@ export class TeamProvisioningService {
|
|||
runId,
|
||||
providerId: 'opencode',
|
||||
cwd: input.request.cwd,
|
||||
members: result.members,
|
||||
});
|
||||
this.aliveRunByTeam.set(input.request.teamName, runId);
|
||||
}
|
||||
|
|
@ -11978,7 +12095,6 @@ export class TeamProvisioningService {
|
|||
async getRuntimeState(teamName: string): Promise<TeamRuntimeState> {
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
||||
const strictLiveness = isStrictTeamMemberLivenessMode();
|
||||
|
||||
if (!run) {
|
||||
const recovered = await readBootstrapRuntimeState(teamName);
|
||||
|
|
@ -12221,6 +12337,11 @@ export class TeamProvisioningService {
|
|||
// Read config.json to get the actual registered members
|
||||
const registeredNames = await this.getRegisteredTeamMemberNames(run.teamName);
|
||||
if (!registeredNames) {
|
||||
try {
|
||||
await fs.promises.access(path.join(getTeamsBasePath(), run.teamName));
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
const now = Date.now();
|
||||
if (
|
||||
shouldWarnOnUnreadableMemberAuditConfig({
|
||||
|
|
@ -12351,7 +12472,6 @@ export class TeamProvisioningService {
|
|||
statuses: Record<string, MemberSpawnStatusEntry>
|
||||
): Promise<Record<string, MemberSpawnStatusEntry>> {
|
||||
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
|
||||
const strictLiveness = isStrictTeamMemberLivenessMode();
|
||||
const nextStatuses = { ...statuses };
|
||||
for (const [memberName, metadata] of runtimeByMember.entries()) {
|
||||
const resolvedStatusKey =
|
||||
|
|
@ -12370,20 +12490,23 @@ export class TeamProvisioningService {
|
|||
if (!current) {
|
||||
continue;
|
||||
}
|
||||
const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
|
||||
const nextEntry: MemberSpawnStatusEntry = {
|
||||
...current,
|
||||
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
||||
...(metadata.livenessKind ? { livenessKind: metadata.livenessKind } : {}),
|
||||
...(metadata.runtimeDiagnostic ? { runtimeDiagnostic: metadata.runtimeDiagnostic } : {}),
|
||||
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||
...(metadata.runtimeDiagnosticSeverity
|
||||
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
|
||||
: {}),
|
||||
livenessLastCheckedAt: nowIso(),
|
||||
};
|
||||
const failureReason = current.hardFailureReason ?? current.error;
|
||||
const hasStrongEvidence = strictLiveness
|
||||
? isStrongRuntimeEvidence(metadata)
|
||||
: metadata.alive === true;
|
||||
const hasStrongEvidence = isStrongRuntimeEvidence(metadata);
|
||||
const hasWeakEvidence =
|
||||
metadata.livenessKind != null &&
|
||||
!isStrongRuntimeEvidence(metadata) &&
|
||||
current.bootstrapConfirmed !== true;
|
||||
if (
|
||||
hasStrongEvidence &&
|
||||
current.hardFailure !== true &&
|
||||
|
|
@ -12412,6 +12535,26 @@ export class TeamProvisioningService {
|
|||
nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
|
||||
nextEntry.launchState = deriveMemberLaunchState(nextEntry);
|
||||
}
|
||||
if (hasWeakEvidence) {
|
||||
nextEntry.runtimeAlive = false;
|
||||
if (nextEntry.livenessSource === 'process') {
|
||||
nextEntry.livenessSource = undefined;
|
||||
}
|
||||
if (
|
||||
current.launchState === 'runtime_pending_bootstrap' ||
|
||||
current.launchState === 'runtime_pending_permission'
|
||||
) {
|
||||
nextEntry.agentToolAccepted = true;
|
||||
}
|
||||
if (
|
||||
current.status === 'online' &&
|
||||
current.hardFailure !== true &&
|
||||
current.launchState !== 'failed_to_start'
|
||||
) {
|
||||
nextEntry.status = nextEntry.agentToolAccepted ? 'waiting' : 'spawning';
|
||||
}
|
||||
nextEntry.launchState = deriveMemberLaunchState(nextEntry);
|
||||
}
|
||||
nextStatuses[resolvedStatusKey] = nextEntry;
|
||||
}
|
||||
return nextStatuses;
|
||||
|
|
@ -12669,7 +12812,6 @@ export class TeamProvisioningService {
|
|||
if (cached && cached.expiresAtMs > Date.now()) {
|
||||
return cached.metadata;
|
||||
}
|
||||
const strictLiveness = isStrictTeamMemberLivenessMode();
|
||||
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
||||
|
|
@ -12817,7 +12959,7 @@ export class TeamProvisioningService {
|
|||
upsertMetadata(memberName, {
|
||||
backendType: 'process',
|
||||
providerId: 'opencode',
|
||||
alive: evidence?.runtimeAlive === true,
|
||||
alive: false,
|
||||
livenessKind: evidence?.livenessKind,
|
||||
pidSource: evidence?.pidSource,
|
||||
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
||||
|
|
@ -12829,34 +12971,42 @@ export class TeamProvisioningService {
|
|||
});
|
||||
}
|
||||
|
||||
const currentRuntimeAdapterRun = this.runtimeAdapterRunByTeam.get(teamName);
|
||||
const persistedLaunchSnapshot = await this.launchStateStore.read(teamName).catch(() => null);
|
||||
for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) {
|
||||
const memberName = persistedMember.name?.trim() ?? '';
|
||||
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
|
||||
continue;
|
||||
}
|
||||
const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
||||
upsertMetadata(memberName, {
|
||||
backendType:
|
||||
persistedMember.providerId === 'opencode'
|
||||
? 'process'
|
||||
: metadataByMember.get(memberName)?.backendType,
|
||||
providerId: persistedMember.providerId,
|
||||
alive: persistedMember.runtimeAlive === true || persistedMember.bootstrapConfirmed === true,
|
||||
livenessKind: persistedMember.livenessKind,
|
||||
pidSource: persistedMember.pidSource,
|
||||
runtimeDiagnostic: persistedMember.runtimeDiagnostic,
|
||||
alive: false,
|
||||
livenessKind: currentRuntimeAdapterEvidence?.livenessKind ?? persistedMember.livenessKind,
|
||||
pidSource: currentRuntimeAdapterEvidence?.pidSource ?? persistedMember.pidSource,
|
||||
runtimeDiagnostic:
|
||||
currentRuntimeAdapterEvidence?.runtimeDiagnostic ?? persistedMember.runtimeDiagnostic,
|
||||
runtimeDiagnosticSeverity: persistedMember.runtimeDiagnosticSeverity,
|
||||
runtimeLastSeenAt:
|
||||
persistedMember.runtimeLastSeenAt ??
|
||||
persistedMember.lastHeartbeatAt ??
|
||||
persistedMember.lastRuntimeAliveAt,
|
||||
...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}),
|
||||
...(typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0
|
||||
? { metricsPid: persistedMember.runtimePid }
|
||||
: {}),
|
||||
...(persistedMember.runtimeSessionId
|
||||
? { runtimeSessionId: persistedMember.runtimeSessionId }
|
||||
: {}),
|
||||
...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' &&
|
||||
currentRuntimeAdapterEvidence.runtimePid > 0
|
||||
? { metricsPid: currentRuntimeAdapterEvidence.runtimePid }
|
||||
: typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0
|
||||
? { metricsPid: persistedMember.runtimePid }
|
||||
: {}),
|
||||
...(currentRuntimeAdapterEvidence?.sessionId
|
||||
? { runtimeSessionId: currentRuntimeAdapterEvidence.sessionId }
|
||||
: persistedMember.runtimeSessionId
|
||||
? { runtimeSessionId: persistedMember.runtimeSessionId }
|
||||
: {}),
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -12891,8 +13041,37 @@ export class TeamProvisioningService {
|
|||
|
||||
for (const [memberName, metadata] of metadataByMember.entries()) {
|
||||
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
||||
const status = this.findTrackedMemberSpawnStatus(run, memberName);
|
||||
const launchMember = persistedLaunchSnapshot?.members[memberName];
|
||||
const adapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
||||
const adapterStatus: MemberSpawnStatusEntry | undefined = adapterEvidence
|
||||
? {
|
||||
status: adapterEvidence.hardFailure
|
||||
? 'error'
|
||||
: adapterEvidence.bootstrapConfirmed
|
||||
? 'online'
|
||||
: adapterEvidence.agentToolAccepted
|
||||
? 'waiting'
|
||||
: 'spawning',
|
||||
launchState: adapterEvidence.launchState,
|
||||
...(adapterEvidence.hardFailureReason
|
||||
? { hardFailureReason: adapterEvidence.hardFailureReason }
|
||||
: {}),
|
||||
...(adapterEvidence.pendingPermissionRequestIds?.length
|
||||
? { pendingPermissionRequestIds: adapterEvidence.pendingPermissionRequestIds }
|
||||
: {}),
|
||||
agentToolAccepted: adapterEvidence.agentToolAccepted,
|
||||
runtimeAlive: adapterEvidence.runtimeAlive,
|
||||
bootstrapConfirmed: adapterEvidence.bootstrapConfirmed,
|
||||
hardFailure: adapterEvidence.hardFailure,
|
||||
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
||||
...(adapterEvidence.livenessKind ? { livenessKind: adapterEvidence.livenessKind } : {}),
|
||||
...(adapterEvidence.runtimeDiagnostic
|
||||
? { runtimeDiagnostic: adapterEvidence.runtimeDiagnostic }
|
||||
: {}),
|
||||
updatedAt: persistedLaunchSnapshot?.updatedAt ?? nowIso(),
|
||||
}
|
||||
: undefined;
|
||||
const status = this.findTrackedMemberSpawnStatus(run, memberName) ?? adapterStatus;
|
||||
const resolved = resolveTeamMemberRuntimeLiveness({
|
||||
teamName,
|
||||
memberName,
|
||||
|
|
@ -12910,15 +13089,9 @@ export class TeamProvisioningService {
|
|||
processTableAvailable,
|
||||
nowIso: nowIso(),
|
||||
});
|
||||
const legacyWeakAlive =
|
||||
resolved.alive ||
|
||||
(resolved.pidSource === 'tmux_pane' && typeof resolved.pid === 'number') ||
|
||||
(metadata.backendType === 'process' &&
|
||||
typeof metadata.metricsPid === 'number' &&
|
||||
metadata.metricsPid > 0);
|
||||
metadataByMember.set(memberName, {
|
||||
...metadata,
|
||||
alive: strictLiveness ? resolved.alive : legacyWeakAlive,
|
||||
alive: resolved.alive,
|
||||
...(typeof resolved.pid === 'number' && resolved.pid > 0 ? { pid: resolved.pid } : {}),
|
||||
...(typeof (resolved.metricsPid ?? metadata.metricsPid) === 'number' &&
|
||||
Number.isFinite(resolved.metricsPid ?? metadata.metricsPid) &&
|
||||
|
|
@ -13050,7 +13223,11 @@ export class TeamProvisioningService {
|
|||
runtimeProcessPendingCount += 1;
|
||||
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
||||
runtimeCandidatePendingCount += 1;
|
||||
} else if (entry.livenessKind === 'not_found' || entry.livenessKind === 'stale_metadata') {
|
||||
} else if (
|
||||
entry.livenessKind === 'not_found' ||
|
||||
entry.livenessKind === 'stale_metadata' ||
|
||||
entry.livenessKind === 'registered_only'
|
||||
) {
|
||||
noRuntimePendingCount += 1;
|
||||
}
|
||||
}
|
||||
|
|
@ -13100,10 +13277,8 @@ export class TeamProvisioningService {
|
|||
}`;
|
||||
}
|
||||
|
||||
const stillStartingCount = Math.max(
|
||||
0,
|
||||
launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount
|
||||
);
|
||||
const runtimeProcessPendingCount = launchSummary.runtimeProcessPendingCount ?? 0;
|
||||
const stillStartingCount = Math.max(0, launchSummary.pendingCount - runtimeProcessPendingCount);
|
||||
const diagnosticParts = [
|
||||
launchSummary.shellOnlyPendingCount
|
||||
? `${launchSummary.shellOnlyPendingCount} shell-only`
|
||||
|
|
@ -13121,16 +13296,15 @@ export class TeamProvisioningService {
|
|||
const diagnosticSuffix = diagnosticParts.length > 0 ? ` - ${diagnosticParts.join(', ')}` : '';
|
||||
if (launchSummary.confirmedCount === 0) {
|
||||
const allRuntimeAlive =
|
||||
launchSummary.runtimeAlivePendingCount > 0 &&
|
||||
launchSummary.runtimeAlivePendingCount === expectedTeammateCount;
|
||||
runtimeProcessPendingCount > 0 && runtimeProcessPendingCount === expectedTeammateCount;
|
||||
return allRuntimeAlive
|
||||
? `${prefix} — teammates online`
|
||||
: launchSummary.runtimeAlivePendingCount > 0
|
||||
? `${prefix} — ${launchSummary.runtimeAlivePendingCount}/${expectedTeammateCount} teammate${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}`
|
||||
: runtimeProcessPendingCount > 0
|
||||
? `${prefix} — ${runtimeProcessPendingCount}/${expectedTeammateCount} teammate${runtimeProcessPendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}`
|
||||
: `${prefix} — teammates are still starting${diagnosticSuffix}`;
|
||||
}
|
||||
|
||||
return `${prefix} — ${launchSummary.confirmedCount}/${expectedTeammateCount} teammates made contact${launchSummary.runtimeAlivePendingCount > 0 ? `, ${launchSummary.runtimeAlivePendingCount} teammate${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online` : ''}${stillStartingCount > 0 ? `${launchSummary.runtimeAlivePendingCount > 0 ? ', ' : ', '}${stillStartingCount} still joining${diagnosticSuffix}` : ''}`;
|
||||
return `${prefix} — ${launchSummary.confirmedCount}/${expectedTeammateCount} teammates made contact${runtimeProcessPendingCount > 0 ? `, ${runtimeProcessPendingCount} teammate${runtimeProcessPendingCount === 1 ? '' : 's'} online` : ''}${stillStartingCount > 0 ? `${runtimeProcessPendingCount > 0 ? ', ' : ', '}${stillStartingCount} still joining${diagnosticSuffix}` : ''}`;
|
||||
}
|
||||
|
||||
private buildAggregatePendingLaunchMessage(
|
||||
|
|
@ -13141,6 +13315,7 @@ export class TeamProvisioningService {
|
|||
pendingCount: number;
|
||||
failedCount: number;
|
||||
runtimeAlivePendingCount: number;
|
||||
runtimeProcessPendingCount?: number;
|
||||
},
|
||||
snapshot?: PersistedTeamLaunchSnapshot | null
|
||||
): string {
|
||||
|
|
@ -13554,10 +13729,14 @@ export class TeamProvisioningService {
|
|||
|
||||
if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') {
|
||||
await this.clearPersistedLaunchState(run.teamName);
|
||||
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
||||
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
||||
return null;
|
||||
}
|
||||
|
||||
await this.launchStateStore.write(run.teamName, filteredSnapshot);
|
||||
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
||||
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
||||
return filteredSnapshot;
|
||||
}
|
||||
|
||||
|
|
@ -14242,7 +14421,7 @@ export class TeamProvisioningService {
|
|||
};
|
||||
}
|
||||
|
||||
const liveAgentNames = await this.getLiveTeamAgentNames(teamName);
|
||||
const liveRuntimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
|
||||
const nextMembers = { ...filteredPersisted.members };
|
||||
const persistedMemberNames = this.getPersistedLaunchMemberNames(filteredPersisted);
|
||||
const now = nowIso();
|
||||
|
|
@ -14262,11 +14441,6 @@ export class TeamProvisioningService {
|
|||
current.firstSpawnAcceptedAt =
|
||||
current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt;
|
||||
}
|
||||
if (bootstrapMember?.runtimeAlive && !current.runtimeAlive) {
|
||||
current.runtimeAlive = true;
|
||||
current.lastRuntimeAliveAt =
|
||||
current.lastRuntimeAliveAt ?? bootstrapMember.lastRuntimeAliveAt;
|
||||
}
|
||||
if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) {
|
||||
current.bootstrapConfirmed = true;
|
||||
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt;
|
||||
|
|
@ -14274,10 +14448,13 @@ export class TeamProvisioningService {
|
|||
const matchedConfigNames = [...configMembers].filter((name) =>
|
||||
matchesObservedMemberNameForExpected(name, expected)
|
||||
);
|
||||
const observedRuntimeAlive = [...liveAgentNames].some((name) =>
|
||||
const runtimeMetadataCandidates = [...liveRuntimeByMember.entries()].filter(([name]) =>
|
||||
matchesObservedMemberNameForExpected(name, expected)
|
||||
);
|
||||
const runtimeAlive = current.runtimeAlive === true || observedRuntimeAlive;
|
||||
const runtimeMetadata =
|
||||
runtimeMetadataCandidates.find(([, metadata]) => metadata.alive) ??
|
||||
runtimeMetadataCandidates[0];
|
||||
const observedRuntimeAlive = runtimeMetadata?.[1].alive === true;
|
||||
const heartbeatMessage = this.selectLatestLeadInboxLaunchReconcileMessage(
|
||||
leadInboxMessages,
|
||||
persistedMemberNames,
|
||||
|
|
@ -14289,11 +14466,15 @@ export class TeamProvisioningService {
|
|||
: null;
|
||||
const acceptedAtMs =
|
||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||
current.runtimeAlive = runtimeAlive;
|
||||
current.runtimeAlive = observedRuntimeAlive;
|
||||
current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt;
|
||||
current.livenessKind = runtimeMetadata?.[1].livenessKind;
|
||||
current.pidSource = runtimeMetadata?.[1].pidSource;
|
||||
current.runtimeDiagnostic = runtimeMetadata?.[1].runtimeDiagnostic;
|
||||
current.runtimeDiagnosticSeverity = runtimeMetadata?.[1].runtimeDiagnosticSeverity;
|
||||
current.sources = {
|
||||
...(current.sources ?? {}),
|
||||
processAlive: runtimeAlive || undefined,
|
||||
processAlive: observedRuntimeAlive || undefined,
|
||||
configRegistered: matchedConfigNames.length > 0 || undefined,
|
||||
configDrift:
|
||||
heartbeatMessage != null && matchedConfigNames.length === 0
|
||||
|
|
@ -15050,13 +15231,14 @@ export class TeamProvisioningService {
|
|||
run.processKilled = true;
|
||||
run.cancelRequested = true;
|
||||
killTeamProcess(run.child);
|
||||
if (this.hasSecondaryRuntimeRuns(teamName)) {
|
||||
await this.stopMixedSecondaryRuntimeLanes(teamName);
|
||||
}
|
||||
const stopSecondaryRuntimeLanes = this.hasSecondaryRuntimeRuns(teamName)
|
||||
? this.stopMixedSecondaryRuntimeLanes(teamName)
|
||||
: null;
|
||||
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
|
||||
run.onProgress(progress);
|
||||
this.cleanupRun(run);
|
||||
logger.info(`[${teamName}] Process stopped (SIGKILL)`);
|
||||
await stopSecondaryRuntimeLanes;
|
||||
}
|
||||
|
||||
private getShutdownTrackedTeamNames(): string[] {
|
||||
|
|
@ -15448,8 +15630,8 @@ export class TeamProvisioningService {
|
|||
killTrackedCliProcesses('SIGKILL');
|
||||
this.killTransientProbeProcessesForShutdown();
|
||||
|
||||
await this.cancelPendingRuntimeAdapterLaunchesForShutdown();
|
||||
const initialTracked = await this.stopTrackedTeamsForShutdown('Shutdown');
|
||||
await this.cancelPendingRuntimeAdapterLaunchesForShutdown();
|
||||
|
||||
// A create/launch may have been inside a per-team lock before it exposed a
|
||||
// run in provisioningRunByTeam. Wait briefly, then rescan to catch anything
|
||||
|
|
@ -15584,7 +15766,15 @@ export class TeamProvisioningService {
|
|||
);
|
||||
return true;
|
||||
}
|
||||
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
|
||||
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
||||
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
||||
this.setMemberSpawnStatus(run, memberName, 'waiting');
|
||||
this.appendMemberBootstrapDiagnostic(
|
||||
run,
|
||||
memberName,
|
||||
'already_running requires strong runtime verification'
|
||||
);
|
||||
void this.reevaluateMemberLaunchStatus(run, memberName);
|
||||
return true;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -128,6 +128,10 @@ function isVerifiedRuntimeProcess(params: {
|
|||
);
|
||||
}
|
||||
|
||||
function isOpenCodeRuntimeProcess(command: string | undefined): boolean {
|
||||
return (command ?? '').toLowerCase().includes('opencode');
|
||||
}
|
||||
|
||||
function hasPersistedEvidence(input: ResolveTeamMemberRuntimeLivenessInput): boolean {
|
||||
return Boolean(
|
||||
input.agentId?.trim() ||
|
||||
|
|
@ -186,18 +190,6 @@ export function resolveTeamMemberRuntimeLiveness(
|
|||
diagnostics.push('process table unavailable');
|
||||
}
|
||||
|
||||
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'],
|
||||
});
|
||||
}
|
||||
|
||||
if (
|
||||
tracked?.launchState === 'runtime_pending_permission' ||
|
||||
(tracked?.pendingPermissionRequestIds?.length ?? 0) > 0
|
||||
|
|
@ -236,15 +228,44 @@ export function resolveTeamMemberRuntimeLiveness(
|
|||
? 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: true,
|
||||
livenessKind: 'runtime_process',
|
||||
alive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
pidSource: 'opencode_bridge',
|
||||
pid: runtimePidRow.pid,
|
||||
runtimeSessionId,
|
||||
processCommand: sanitizeProcessCommandForDiagnostics(runtimePidRow.command),
|
||||
runtimeDiagnostic: 'OpenCode runtime process detected',
|
||||
diagnostics: [...diagnostics, 'matched OpenCode runtime pid in process table'],
|
||||
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'],
|
||||
});
|
||||
}
|
||||
|
||||
|
|
@ -311,6 +332,18 @@ export function resolveTeamMemberRuntimeLiveness(
|
|||
}
|
||||
|
||||
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',
|
||||
|
|
|
|||
|
|
@ -170,6 +170,11 @@ export class TeamTaskReader {
|
|||
completedAt: i.completedAt,
|
||||
}))
|
||||
: undefined;
|
||||
const status = (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
||||
parsed.status as TeamTask['status']
|
||||
)
|
||||
? (parsed.status as TeamTask['status'])
|
||||
: 'pending';
|
||||
const task: TeamTask = {
|
||||
id:
|
||||
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
|
|
@ -192,11 +197,7 @@ export class TeamTaskReader {
|
|||
promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs),
|
||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
||||
parsed.status as TeamTask['status']
|
||||
)
|
||||
? (parsed.status as TeamTask['status'])
|
||||
: 'pending',
|
||||
status,
|
||||
workIntervals,
|
||||
historyEvents,
|
||||
blocks: Array.isArray(parsed.blocks)
|
||||
|
|
@ -299,6 +300,7 @@ export class TeamTaskReader {
|
|||
reviewState: getReviewStateFromTask({
|
||||
historyEvents,
|
||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||
status,
|
||||
}),
|
||||
sourceMessageId:
|
||||
typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim()
|
||||
|
|
@ -413,6 +415,7 @@ export class TeamTaskReader {
|
|||
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
||||
reviewState: getReviewStateFromTask({
|
||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||
status: 'deleted',
|
||||
}),
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
import { configManager } from '../infrastructure/ConfigManager';
|
||||
|
||||
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
|
||||
|
||||
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator';
|
||||
|
|
@ -18,8 +16,7 @@ export function getConfiguredCliFlavor(): CliFlavor {
|
|||
return envOverride;
|
||||
}
|
||||
|
||||
const multimodelEnabled = configManager.getConfig().general.multimodelEnabled;
|
||||
return multimodelEnabled ? 'agent_teams_orchestrator' : 'claude';
|
||||
return DEFAULT_CLI_FLAVOR;
|
||||
}
|
||||
|
||||
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ export const PROGRESS_LOG_TAIL_LINES = 200;
|
|||
export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
|
||||
export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20;
|
||||
const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500;
|
||||
const SECRET_FLAG_PATTERN =
|
||||
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
|
||||
|
||||
/**
|
||||
* Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n"
|
||||
|
|
@ -60,9 +62,10 @@ function boundDiagnosticText(value: string | undefined): string | undefined {
|
|||
if (!trimmed) {
|
||||
return undefined;
|
||||
}
|
||||
return trimmed.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT
|
||||
? `${trimmed.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...`
|
||||
: trimmed;
|
||||
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(
|
||||
|
|
|
|||
|
|
@ -302,7 +302,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
providerId: this.providerId,
|
||||
launchState: member.launchState,
|
||||
agentToolAccepted: member.agentToolAccepted,
|
||||
runtimeAlive: member.runtimeAlive,
|
||||
runtimeAlive: member.bootstrapConfirmed === true,
|
||||
bootstrapConfirmed: member.bootstrapConfirmed,
|
||||
hardFailure: member.hardFailure,
|
||||
hardFailureReason: member.hardFailureReason,
|
||||
|
|
@ -544,7 +544,6 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
diagnostics: string[]
|
||||
): TeamRuntimeMemberLaunchEvidence {
|
||||
const confirmed = launchState === 'confirmed_alive';
|
||||
const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked';
|
||||
const failed = launchState === 'failed';
|
||||
const hasRuntimePid =
|
||||
typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0;
|
||||
|
|
@ -552,14 +551,14 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
const livenessKind = confirmed
|
||||
? 'confirmed_bootstrap'
|
||||
: pendingRuntimeObserved
|
||||
? 'runtime_process'
|
||||
? 'runtime_process_candidate'
|
||||
: launchState === 'permission_blocked'
|
||||
? 'permission_blocked'
|
||||
: runtimeMaterialized || sessionId
|
||||
? 'runtime_process_candidate'
|
||||
: 'registered_only';
|
||||
const runtimeDiagnostic = pendingRuntimeObserved
|
||||
? 'OpenCode runtime process reported by bridge'
|
||||
? 'OpenCode runtime pid reported by bridge without local process verification'
|
||||
: launchState === 'permission_blocked'
|
||||
? 'OpenCode runtime is waiting for permission approval'
|
||||
: runtimeMaterialized || sessionId
|
||||
|
|
@ -575,8 +574,13 @@ function mapBridgeMemberToRuntimeEvidence(
|
|||
: launchState === 'permission_blocked'
|
||||
? 'runtime_pending_permission'
|
||||
: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: confirmed || createdOrBlocked || runtimeMaterialized,
|
||||
runtimeAlive: confirmed || pendingRuntimeObserved,
|
||||
agentToolAccepted:
|
||||
confirmed ||
|
||||
pendingRuntimeObserved ||
|
||||
launchState === 'permission_blocked' ||
|
||||
runtimeMaterialized ||
|
||||
Boolean(sessionId),
|
||||
runtimeAlive: confirmed,
|
||||
bootstrapConfirmed: confirmed,
|
||||
hardFailure: failed,
|
||||
hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined,
|
||||
|
|
|
|||
|
|
@ -31,6 +31,10 @@ export function encodePath(absolutePath: string): string {
|
|||
}
|
||||
|
||||
const encoded = absolutePath.replace(/[/\\]/g, '-');
|
||||
const windowsDriveMatch = /^([a-zA-Z]):-(.*)$/.exec(encoded);
|
||||
if (windowsDriveMatch) {
|
||||
return `${windowsDriveMatch[1].toUpperCase()}--${windowsDriveMatch[2]}`;
|
||||
}
|
||||
|
||||
// Ensure leading dash for absolute paths
|
||||
return encoded.startsWith('-') ? encoded : `-${encoded}`;
|
||||
|
|
@ -50,7 +54,7 @@ export function decodePath(encodedName: string): string {
|
|||
|
||||
// Legacy Windows format observed in some Claude installs: "C--Users-name-project"
|
||||
// (no leading dash, drive separator encoded as "--").
|
||||
const legacyWindowsRegex = /^([a-zA-Z])--(.+)$/;
|
||||
const legacyWindowsRegex = /^([a-zA-Z])--(.*)$/;
|
||||
const legacyWindowsMatch = legacyWindowsRegex.exec(encodedName);
|
||||
if (legacyWindowsMatch) {
|
||||
const drive = legacyWindowsMatch[1].toUpperCase();
|
||||
|
|
|
|||
|
|
@ -97,6 +97,13 @@ interface TaskReadDiag {
|
|||
|
||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
||||
const REVIEW_LIFECYCLE_EVENTS = new Set([
|
||||
'review_requested',
|
||||
'review_changes_requested',
|
||||
'review_approved',
|
||||
'review_started',
|
||||
]);
|
||||
const REVIEW_RESET_STATUSES = new Set(['in_progress', 'deleted']);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Parsed JSON types (loose shapes from disk)
|
||||
|
|
@ -743,28 +750,66 @@ function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefin
|
|||
.map((i) => ({ ...i }));
|
||||
}
|
||||
|
||||
/** Derive review state from historyEvents (inline reducer for worker isolation). */
|
||||
function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string {
|
||||
if (!Array.isArray(events) || events.length === 0) return 'none';
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
const t = e.type;
|
||||
if (
|
||||
t === 'review_requested' ||
|
||||
t === 'review_changes_requested' ||
|
||||
t === 'review_approved' ||
|
||||
t === 'review_started'
|
||||
) {
|
||||
const to = typeof e.to === 'string' ? e.to : 'none';
|
||||
return to === 'review' || to === 'needsFix' || to === 'approved' ? to : 'none';
|
||||
function normalizeReviewState(value: unknown): string {
|
||||
return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none';
|
||||
}
|
||||
|
||||
function normalizeFallbackReviewState(value: unknown, status: string): string {
|
||||
const reviewState = normalizeReviewState(value);
|
||||
if (reviewState === 'none') return 'none';
|
||||
if (status === 'in_progress' || status === 'deleted') return 'none';
|
||||
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
||||
if (status === 'completed') {
|
||||
return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none';
|
||||
}
|
||||
return reviewState;
|
||||
}
|
||||
|
||||
function eventReviewState(event: RawHistoryEvent): string | null {
|
||||
const type = typeof event.type === 'string' ? event.type : '';
|
||||
if (!REVIEW_LIFECYCLE_EVENTS.has(type)) {
|
||||
return null;
|
||||
}
|
||||
return normalizeReviewState(event.to);
|
||||
}
|
||||
|
||||
function derivePendingReviewState(events: RawHistoryEvent[], startIndex: number): string {
|
||||
for (let i = startIndex - 1; i >= 0; i--) {
|
||||
const previous = events[i];
|
||||
const reviewState = eventReviewState(previous);
|
||||
if (reviewState) {
|
||||
return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
||||
}
|
||||
if (t === 'status_changed' && e.to === 'in_progress') {
|
||||
if (
|
||||
previous.type === 'task_created' ||
|
||||
(previous.type === 'status_changed' &&
|
||||
(REVIEW_RESET_STATUSES.has(String(previous.to || '')) || previous.to === 'pending'))
|
||||
) {
|
||||
return 'none';
|
||||
}
|
||||
}
|
||||
return 'none';
|
||||
}
|
||||
|
||||
/** Derive review state from historyEvents (inline reducer for worker isolation). */
|
||||
function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string | null {
|
||||
if (!Array.isArray(events) || events.length === 0) return null;
|
||||
for (let i = events.length - 1; i >= 0; i--) {
|
||||
const e = events[i];
|
||||
const reviewState = eventReviewState(e);
|
||||
if (reviewState) {
|
||||
return reviewState;
|
||||
}
|
||||
if (e.type === 'status_changed' && REVIEW_RESET_STATUSES.has(String(e.to || ''))) {
|
||||
return 'none';
|
||||
}
|
||||
if (e.type === 'status_changed' && e.to === 'pending') {
|
||||
return derivePendingReviewState(events, i);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
||||
if (!Array.isArray(parsed.comments)) return undefined;
|
||||
return (parsed.comments as unknown[])
|
||||
|
|
@ -869,7 +914,16 @@ async function readTasksDirForTeam(
|
|||
? (parsed.needsClarification as string)
|
||||
: undefined;
|
||||
const historyEvents = normalizeHistoryEvents(parsed);
|
||||
const reviewState = deriveReviewStateFromEvents(historyEvents);
|
||||
const status =
|
||||
parsed.status === 'pending' ||
|
||||
parsed.status === 'in_progress' ||
|
||||
parsed.status === 'completed' ||
|
||||
parsed.status === 'deleted'
|
||||
? (parsed.status as string)
|
||||
: 'pending';
|
||||
const reviewState =
|
||||
deriveReviewStateFromEvents(historyEvents) ??
|
||||
normalizeFallbackReviewState(parsed.reviewState, status);
|
||||
|
||||
tasks.push({
|
||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||
|
|
@ -896,13 +950,7 @@ async function readTasksDirForTeam(
|
|||
: undefined,
|
||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||
status:
|
||||
parsed.status === 'pending' ||
|
||||
parsed.status === 'in_progress' ||
|
||||
parsed.status === 'completed' ||
|
||||
parsed.status === 'deleted'
|
||||
? (parsed.status as string)
|
||||
: 'pending',
|
||||
status,
|
||||
workIntervals: normalizeWorkIntervals(parsed),
|
||||
historyEvents: normalizeHistoryEvents(parsed),
|
||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
||||
|
|
|
|||
|
|
@ -32,7 +32,6 @@ import {
|
|||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
||||
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||
|
|
@ -265,11 +264,8 @@ interface InstalledBannerProps {
|
|||
cliStatusError: string | null;
|
||||
providersCollapsed: boolean;
|
||||
isBusy: boolean;
|
||||
multimodelEnabled: boolean;
|
||||
multimodelBusy: boolean;
|
||||
onInstall: () => void;
|
||||
onRefresh: () => void;
|
||||
onMultimodelToggle: (enabled: boolean) => void;
|
||||
onToggleProvidersCollapsed: () => void;
|
||||
onProviderLogin: (providerId: CliProviderId) => void;
|
||||
onProviderLogout: (providerId: CliProviderId) => void;
|
||||
|
|
@ -573,11 +569,8 @@ const InstalledBanner = ({
|
|||
cliStatusError,
|
||||
providersCollapsed,
|
||||
isBusy,
|
||||
multimodelEnabled,
|
||||
multimodelBusy,
|
||||
onInstall,
|
||||
onRefresh,
|
||||
onMultimodelToggle,
|
||||
onToggleProvidersCollapsed,
|
||||
onProviderLogin,
|
||||
onProviderLogout,
|
||||
|
|
@ -683,23 +676,6 @@ const InstalledBanner = ({
|
|||
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||
Multimodel
|
||||
</span>
|
||||
{multimodelEnabled && (
|
||||
<span
|
||||
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.35)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
<SettingsToggle
|
||||
enabled={multimodelEnabled}
|
||||
onChange={onMultimodelToggle}
|
||||
disabled={isBusy || cliStatusLoading || multimodelBusy}
|
||||
/>
|
||||
</div>
|
||||
{/* Extensions button — available whenever the runtime is installed */}
|
||||
{canOpenExtensions && (
|
||||
|
|
@ -1033,7 +1009,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('anthropic');
|
||||
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
||||
const [providersCollapsed, setProvidersCollapsed] = useState(() =>
|
||||
loadDashboardCliStatusBannerCollapsed()
|
||||
|
|
@ -1147,37 +1122,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
})();
|
||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
||||
|
||||
const handleMultimodelToggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
setIsSwitchingFlavor(true);
|
||||
let nextMultimodelEnabled = multimodelEnabled;
|
||||
try {
|
||||
useStore.setState({
|
||||
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
|
||||
cliStatusLoading: true,
|
||||
cliStatusError: null,
|
||||
});
|
||||
await updateConfig('general', { multimodelEnabled: enabled });
|
||||
nextMultimodelEnabled = enabled;
|
||||
await invalidateCliStatus();
|
||||
if (enabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} catch {
|
||||
if (nextMultimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsSwitchingFlavor(false);
|
||||
}
|
||||
},
|
||||
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig]
|
||||
);
|
||||
|
||||
const recheckAuthState = useCallback(() => {
|
||||
setIsVerifyingAuth(true);
|
||||
void (async () => {
|
||||
|
|
@ -1422,11 +1366,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
|
|
@ -1650,11 +1591,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
|
|
@ -1712,11 +1650,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
|
|
@ -1934,11 +1869,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
|||
cliStatusError={cliStatusError ?? null}
|
||||
providersCollapsed={providersCollapsed}
|
||||
isBusy={isBusy}
|
||||
multimodelEnabled={multimodelEnabled}
|
||||
multimodelBusy={isSwitchingFlavor}
|
||||
onInstall={handleInstall}
|
||||
onRefresh={handleRefresh}
|
||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||
onProviderLogin={handleProviderLogin}
|
||||
onProviderLogout={handleProviderLogout}
|
||||
|
|
|
|||
|
|
@ -27,7 +27,6 @@ import {
|
|||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||
import { useStore } from '@renderer/store';
|
||||
|
|
@ -211,7 +210,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
} | null>(null);
|
||||
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
|
||||
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||
const loadingCliStatus =
|
||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||
|
|
@ -323,37 +321,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
})();
|
||||
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
|
||||
|
||||
const handleMultimodelToggle = useCallback(
|
||||
async (enabled: boolean) => {
|
||||
setIsSwitchingFlavor(true);
|
||||
let nextMultimodelEnabled = multimodelEnabled;
|
||||
try {
|
||||
useStore.setState({
|
||||
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
|
||||
cliStatusLoading: true,
|
||||
cliStatusError: null,
|
||||
});
|
||||
await updateConfig('general', { multimodelEnabled: enabled });
|
||||
nextMultimodelEnabled = enabled;
|
||||
await invalidateCliStatus();
|
||||
if (enabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} catch {
|
||||
if (nextMultimodelEnabled) {
|
||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
||||
} else {
|
||||
await fetchCliStatus();
|
||||
}
|
||||
} finally {
|
||||
setIsSwitchingFlavor(false);
|
||||
}
|
||||
},
|
||||
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig]
|
||||
);
|
||||
|
||||
const handleRuntimeBackendChange = useCallback(
|
||||
async (providerId: CliProviderId, backendId: string) => {
|
||||
const currentBackends = appConfig?.runtime?.providerBackends ?? {
|
||||
|
|
@ -440,23 +407,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
|||
>
|
||||
Multimodel
|
||||
</span>
|
||||
{multimodelEnabled && (
|
||||
<span
|
||||
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
|
||||
style={{
|
||||
borderColor: 'rgba(245, 158, 11, 0.35)',
|
||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
||||
color: '#fbbf24',
|
||||
}}
|
||||
>
|
||||
Beta
|
||||
</span>
|
||||
)}
|
||||
<SettingsToggle
|
||||
enabled={multimodelEnabled}
|
||||
onChange={(value) => void handleMultimodelToggle(value)}
|
||||
disabled={isBusy || cliStatusLoading || isSwitchingFlavor}
|
||||
/>
|
||||
</div>
|
||||
{/* Inline action buttons */}
|
||||
{effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (
|
||||
|
|
|
|||
|
|
@ -167,6 +167,9 @@ export const ProvisioningProgressBlock = ({
|
|||
const outputScrollRef = useRef<HTMLDivElement>(null);
|
||||
const isError = tone === 'error';
|
||||
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
|
||||
const visibleLaunchDiagnostics =
|
||||
launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ??
|
||||
[];
|
||||
|
||||
// Auto-scroll assistant output
|
||||
useEffect(() => {
|
||||
|
|
@ -298,7 +301,7 @@ export const ProvisioningProgressBlock = ({
|
|||
errorIndex={errorStepIndex}
|
||||
/>
|
||||
</div>
|
||||
{launchDiagnostics && launchDiagnostics.length > 0 ? (
|
||||
{visibleLaunchDiagnostics.length > 0 ? (
|
||||
<div className="mt-2">
|
||||
<button
|
||||
type="button"
|
||||
|
|
@ -310,7 +313,7 @@ export const ProvisioningProgressBlock = ({
|
|||
</button>
|
||||
{diagnosticsOpen ? (
|
||||
<div className="mt-1 space-y-1 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
||||
{launchDiagnostics.map((item) => (
|
||||
{visibleLaunchDiagnostics.map((item) => (
|
||||
<div key={item.id} className="text-[11px]">
|
||||
<div
|
||||
className={cn(
|
||||
|
|
|
|||
|
|
@ -257,7 +257,7 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
|
|||
memberCount: team.memberCount,
|
||||
expectedMemberCount: team.expectedMemberCount,
|
||||
confirmedCount: team.confirmedCount,
|
||||
runtimeAlivePendingCount: team.runtimeAlivePendingCount,
|
||||
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
|
||||
teamLaunchState: team.teamLaunchState,
|
||||
partialLaunchFailure: team.partialLaunchFailure,
|
||||
missingMemberCount: team.missingMembers?.length ?? 0,
|
||||
|
|
@ -267,12 +267,12 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
|
|||
|
||||
const message =
|
||||
summary?.teamLaunchState === 'partial_pending'
|
||||
? summary.runtimeAlivePendingCount != null && summary.runtimeAlivePendingCount > 0
|
||||
? summary.runtimeProcessPendingCount != null && summary.runtimeProcessPendingCount > 0
|
||||
? buildPendingRuntimeSummaryCopy({
|
||||
confirmedCount: summary.confirmedCount,
|
||||
expectedMemberCount: summary.expectedMemberCount,
|
||||
memberCount: summary.memberCount,
|
||||
runtimeAlivePendingCount: summary.runtimeAlivePendingCount,
|
||||
runtimeProcessPendingCount: summary.runtimeProcessPendingCount,
|
||||
})
|
||||
: 'Last launch is still reconciling'
|
||||
: summary?.partialLaunchFailure
|
||||
|
|
@ -809,6 +809,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
|||
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
|
||||
[runtimeSnapshot?.members]
|
||||
);
|
||||
const runtimeRunId = runtimeSnapshot?.runId ?? memberSpawnSnapshot?.runId ?? progress?.runId;
|
||||
const isLaunchSettling = useMemo(() => {
|
||||
if (progress?.state !== 'ready') {
|
||||
return false;
|
||||
|
|
@ -828,6 +829,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
|||
leadActivity={leadActivity}
|
||||
memberSpawnStatuses={memberSpawnStatusMap}
|
||||
memberRuntimeEntries={memberRuntimeMap}
|
||||
runtimeRunId={runtimeRunId}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
/>
|
||||
);
|
||||
|
|
@ -889,6 +891,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
memberSpawnStatuses,
|
||||
memberSpawnSnapshot,
|
||||
spawnEntry,
|
||||
runtimeRunId,
|
||||
runtimeEntry,
|
||||
} = useStore(
|
||||
useShallow((s) => ({
|
||||
|
|
@ -899,6 +902,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
||||
runtimeRunId:
|
||||
s.teamAgentRuntimeByTeam[teamName]?.runId ??
|
||||
s.memberSpawnSnapshotsByTeam[teamName]?.runId ??
|
||||
getCurrentProvisioningProgressForTeam(s, teamName)?.runId,
|
||||
runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined,
|
||||
}))
|
||||
);
|
||||
|
|
@ -924,6 +931,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
|||
leadActivity={leadActivity}
|
||||
spawnEntry={spawnEntry}
|
||||
runtimeEntry={runtimeEntry}
|
||||
runtimeRunId={runtimeRunId}
|
||||
/>
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -981,12 +981,12 @@ export const TeamListView = (): React.JSX.Element => {
|
|||
</div>
|
||||
{team.teamLaunchState === 'partial_pending' ? (
|
||||
<p className="mt-2 text-[11px] text-amber-300">
|
||||
{team.runtimeAlivePendingCount && team.runtimeAlivePendingCount > 0
|
||||
{team.runtimeProcessPendingCount && team.runtimeProcessPendingCount > 0
|
||||
? buildPendingRuntimeSummaryCopy({
|
||||
confirmedCount: team.confirmedCount,
|
||||
expectedMemberCount: team.expectedMemberCount,
|
||||
memberCount: team.memberCount,
|
||||
runtimeAlivePendingCount: team.runtimeAlivePendingCount,
|
||||
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
|
||||
includePeriod: true,
|
||||
})
|
||||
: 'Last launch is still reconciling.'}
|
||||
|
|
|
|||
|
|
@ -86,6 +86,8 @@ import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils
|
|||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||
|
||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
||||
import { CodexFastModeSelector } from './CodexFastModeSelector';
|
||||
import {
|
||||
clearInheritedMemberModelsUnavailableForProvider,
|
||||
resolveProviderScopedMemberModel,
|
||||
|
|
@ -1421,10 +1423,12 @@ export const CreateTeamDialog = ({
|
|||
const summary: string[] = [];
|
||||
if (prompt.trim()) summary.push('Lead prompt');
|
||||
if (skipPermissions) summary.push('Auto-approve tools');
|
||||
if (selectedProviderId === 'anthropic') {
|
||||
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
|
||||
if (selectedFastMode === 'on') summary.push('Fast mode');
|
||||
else if (selectedFastMode === 'off') summary.push('Fast disabled');
|
||||
else if (anthropicProviderFastModeDefault) summary.push('Fast default');
|
||||
else if (selectedProviderId === 'anthropic' && anthropicProviderFastModeDefault) {
|
||||
summary.push('Fast default');
|
||||
}
|
||||
}
|
||||
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
||||
if (customArgs.trim()) summary.push('Custom CLI args');
|
||||
|
|
@ -1721,9 +1725,6 @@ export const CreateTeamDialog = ({
|
|||
onProviderChange={setSelectedProviderId}
|
||||
onModelChange={setSelectedModel}
|
||||
onEffortChange={setSelectedEffort}
|
||||
fastMode={selectedFastMode}
|
||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
||||
onFastModeChange={setSelectedFastMode}
|
||||
onLimitContextChange={setLimitContext}
|
||||
syncModelsWithTeammates={syncModelsWithLead}
|
||||
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
||||
|
|
@ -1732,7 +1733,6 @@ export const CreateTeamDialog = ({
|
|||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
disableGeminiOption={isGeminiUiFrozen()}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
leadFastModeNotice={anthropicRuntimeNotice}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
headerTop={
|
||||
<div className="flex items-center gap-2">
|
||||
|
|
@ -1818,6 +1818,47 @@ export const CreateTeamDialog = ({
|
|||
summary={launchOptionalSummary}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{selectedProviderId === 'anthropic' ? (
|
||||
<div className="space-y-2">
|
||||
<AnthropicFastModeSelector
|
||||
value={selectedFastMode}
|
||||
onValueChange={setSelectedFastMode}
|
||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
||||
model={selectedModel}
|
||||
limitContext={limitContext}
|
||||
id="create-fast-mode"
|
||||
/>
|
||||
{anthropicRuntimeNotice ? (
|
||||
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||
<p>{anthropicRuntimeNotice}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedProviderId === 'codex' ? (
|
||||
<div className="space-y-2">
|
||||
<CodexFastModeSelector
|
||||
value={selectedFastMode}
|
||||
onValueChange={setSelectedFastMode}
|
||||
model={selectedModel}
|
||||
providerBackendId={
|
||||
resolveUiOwnedProviderBackendId(
|
||||
'codex',
|
||||
runtimeProviderStatusById.get('codex')
|
||||
) ?? undefined
|
||||
}
|
||||
id="create-fast-mode"
|
||||
/>
|
||||
{anthropicRuntimeNotice ? (
|
||||
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||
<p>{anthropicRuntimeNotice}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="team-prompt" className="label-optional">
|
||||
Prompt for team lead (optional)
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ import {
|
|||
CheckCircle2,
|
||||
ChevronDown,
|
||||
ChevronRight,
|
||||
Info,
|
||||
Loader2,
|
||||
RotateCcw,
|
||||
X,
|
||||
|
|
@ -1618,10 +1619,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
|
||||
if (selectedModel) summary.push(`Model: ${selectedModel}`);
|
||||
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
|
||||
if (selectedProviderId === 'anthropic') {
|
||||
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
|
||||
if (selectedFastMode === 'on') summary.push('Fast mode');
|
||||
else if (selectedFastMode === 'off') summary.push('Fast disabled');
|
||||
else if (anthropicProviderFastModeDefault) summary.push('Fast default');
|
||||
else if (selectedProviderId === 'anthropic' && anthropicProviderFastModeDefault) {
|
||||
summary.push('Fast default');
|
||||
}
|
||||
}
|
||||
if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context');
|
||||
if (skipPermissions) summary.push('Auto-approve tools');
|
||||
|
|
@ -2263,6 +2266,52 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
summary={launchOptionalSummary}
|
||||
>
|
||||
<div className="space-y-4">
|
||||
{selectedProviderId === 'anthropic' ? (
|
||||
<div className="space-y-2">
|
||||
<AnthropicFastModeSelector
|
||||
value={selectedFastMode}
|
||||
onValueChange={setSelectedFastMode}
|
||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
||||
model={selectedModel}
|
||||
limitContext={effectiveAnthropicRuntimeLimitContext}
|
||||
id="launch-fast-mode"
|
||||
/>
|
||||
{anthropicRuntimeNotice ? (
|
||||
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||
<p>{anthropicRuntimeNotice}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
{selectedProviderId === 'codex' ? (
|
||||
<div className="space-y-2">
|
||||
<CodexFastModeSelector
|
||||
value={selectedFastMode}
|
||||
onValueChange={setSelectedFastMode}
|
||||
model={selectedModel}
|
||||
providerBackendId={
|
||||
resolveUiOwnedProviderBackendId(
|
||||
'codex',
|
||||
runtimeProviderStatusById.get('codex')
|
||||
) ??
|
||||
migrateProviderBackendId(
|
||||
'codex',
|
||||
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
|
||||
) ??
|
||||
undefined
|
||||
}
|
||||
id="launch-fast-mode"
|
||||
/>
|
||||
{anthropicRuntimeNotice ? (
|
||||
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||
<p>{anthropicRuntimeNotice}</p>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<TeamRosterEditorSection
|
||||
members={membersDrafts}
|
||||
onMembersChange={setMembersDrafts}
|
||||
|
|
@ -2285,13 +2334,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
providerId={selectedProviderId}
|
||||
model={selectedModel}
|
||||
effort={(selectedEffort as EffortLevel) || undefined}
|
||||
fastMode={selectedFastMode}
|
||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
||||
limitContext={limitContext}
|
||||
onProviderChange={setSelectedProviderId}
|
||||
onModelChange={setSelectedModel}
|
||||
onEffortChange={setSelectedEffort}
|
||||
onFastModeChange={setSelectedFastMode}
|
||||
onLimitContextChange={setLimitContext}
|
||||
syncModelsWithTeammates={syncModelsWithLead}
|
||||
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||
|
|
@ -2299,7 +2345,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||
leadWarningText={leadRuntimeWarningText}
|
||||
leadFastModeNotice={anthropicRuntimeNotice}
|
||||
memberWarningById={memberRuntimeWarningById}
|
||||
leadModelIssueText={leadModelIssueText}
|
||||
memberModelIssueById={memberModelIssueById}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
|
||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||
import { AnthropicFastModeSelector } from '@renderer/components/team/dialogs/AnthropicFastModeSelector';
|
||||
import { CodexFastModeSelector } from '@renderer/components/team/dialogs/CodexFastModeSelector';
|
||||
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
||||
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
|
||||
import {
|
||||
|
|
@ -22,24 +20,20 @@ import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
|||
|
||||
import { Button } from '../../ui/button';
|
||||
|
||||
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
interface LeadModelRowProps {
|
||||
providerId: TeamProviderId;
|
||||
model: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
providerFastModeDefault?: boolean;
|
||||
limitContext: boolean;
|
||||
onProviderChange: (providerId: TeamProviderId) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onEffortChange: (effort: string) => void;
|
||||
onFastModeChange?: (fastMode: TeamFastMode) => void;
|
||||
onLimitContextChange: (value: boolean) => void;
|
||||
syncModelsWithTeammates: boolean;
|
||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||
warningText?: string | null;
|
||||
fastModeNotice?: string | null;
|
||||
disableGeminiOption?: boolean;
|
||||
modelIssueText?: string | null;
|
||||
}
|
||||
|
|
@ -48,18 +42,14 @@ export const LeadModelRow = ({
|
|||
providerId,
|
||||
model,
|
||||
effort,
|
||||
fastMode = 'inherit',
|
||||
providerFastModeDefault = false,
|
||||
limitContext,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onEffortChange,
|
||||
onFastModeChange,
|
||||
onLimitContextChange,
|
||||
syncModelsWithTeammates,
|
||||
onSyncModelsWithTeammatesChange,
|
||||
warningText,
|
||||
fastModeNotice,
|
||||
disableGeminiOption = false,
|
||||
modelIssueText,
|
||||
}: LeadModelRowProps): React.JSX.Element => {
|
||||
|
|
@ -169,24 +159,6 @@ export const LeadModelRow = ({
|
|||
model={model}
|
||||
limitContext={limitContext}
|
||||
/>
|
||||
{providerId === 'anthropic' && onFastModeChange ? (
|
||||
<AnthropicFastModeSelector
|
||||
value={fastMode}
|
||||
onValueChange={onFastModeChange}
|
||||
providerFastModeDefault={providerFastModeDefault}
|
||||
model={model}
|
||||
limitContext={limitContext}
|
||||
id="lead-fast-mode"
|
||||
/>
|
||||
) : null}
|
||||
{providerId === 'codex' && onFastModeChange ? (
|
||||
<CodexFastModeSelector
|
||||
value={fastMode}
|
||||
onValueChange={onFastModeChange}
|
||||
model={model}
|
||||
id="lead-fast-mode"
|
||||
/>
|
||||
) : null}
|
||||
{providerId === 'anthropic' ? (
|
||||
<LimitContextCheckbox
|
||||
id="lead-limit-context"
|
||||
|
|
@ -195,12 +167,6 @@ export const LeadModelRow = ({
|
|||
disabled={isAnthropicHaikuTeamModel(model)}
|
||||
/>
|
||||
) : null}
|
||||
{fastModeNotice ? (
|
||||
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||
<p>{fastModeNotice}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||
|
|
|
|||
|
|
@ -13,10 +13,17 @@ import {
|
|||
buildMemberLaunchPresentation,
|
||||
displayMemberName,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
buildMemberLaunchDiagnosticsPayload,
|
||||
hasMemberLaunchDiagnosticsDetails,
|
||||
hasMemberLaunchDiagnosticsError,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||
import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||
|
||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
|
||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||
import type {
|
||||
|
|
@ -24,6 +31,7 @@ import type {
|
|||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
MemberSpawnStatusEntry,
|
||||
ResolvedTeamMember,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamTaskWithKanban,
|
||||
|
|
@ -34,6 +42,7 @@ interface MemberCardProps {
|
|||
memberColor: string;
|
||||
runtimeSummary?: string;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
runtimeRunId?: string | null;
|
||||
taskCounts?: TaskStatusCounts | null;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
|
|
@ -43,6 +52,7 @@ interface MemberCardProps {
|
|||
isAwaitingReply?: boolean;
|
||||
isRemoved?: boolean;
|
||||
spawnStatus?: MemberSpawnStatus;
|
||||
spawnEntry?: MemberSpawnStatusEntry;
|
||||
spawnError?: string;
|
||||
spawnLivenessSource?: MemberSpawnLivenessSource;
|
||||
spawnLaunchState?: MemberLaunchState;
|
||||
|
|
@ -80,6 +90,7 @@ export const MemberCard = ({
|
|||
memberColor,
|
||||
runtimeSummary,
|
||||
runtimeEntry,
|
||||
runtimeRunId,
|
||||
taskCounts,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
|
|
@ -89,6 +100,7 @@ export const MemberCard = ({
|
|||
isAwaitingReply,
|
||||
isRemoved,
|
||||
spawnStatus,
|
||||
spawnEntry,
|
||||
spawnError,
|
||||
spawnLivenessSource,
|
||||
spawnLaunchState,
|
||||
|
|
@ -150,6 +162,7 @@ export const MemberCard = ({
|
|||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||
const { summary: runtimeSummaryText, memory: memoryLabel } =
|
||||
splitRuntimeSummaryMemory(runtimeSummary);
|
||||
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
||||
const activityTask = currentTask ?? reviewTask ?? null;
|
||||
const activityTitle = currentTask
|
||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||
|
|
@ -174,6 +187,33 @@ export const MemberCard = ({
|
|||
launchVisualState === 'registered_only' ||
|
||||
launchVisualState === 'stale_runtime');
|
||||
const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel;
|
||||
const launchDiagnosticsPayload = useMemo(
|
||||
() =>
|
||||
buildMemberLaunchDiagnosticsPayload({
|
||||
teamName: selectedTeamName,
|
||||
runId: runtimeRunId,
|
||||
memberName: member.name,
|
||||
spawnStatus,
|
||||
launchState: spawnLaunchState,
|
||||
livenessSource: spawnLivenessSource,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
}),
|
||||
[
|
||||
member.name,
|
||||
runtimeEntry,
|
||||
runtimeRunId,
|
||||
selectedTeamName,
|
||||
spawnEntry,
|
||||
spawnLaunchState,
|
||||
spawnLivenessSource,
|
||||
spawnStatus,
|
||||
]
|
||||
);
|
||||
const showCopyDiagnostics =
|
||||
!isRemoved &&
|
||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||
const showRuntimeAdvisoryBadge =
|
||||
!isRemoved &&
|
||||
Boolean(runtimeAdvisoryLabel) &&
|
||||
|
|
@ -297,16 +337,7 @@ export const MemberCard = ({
|
|||
<span className="shrink-0 opacity-60">•</span>
|
||||
) : null}
|
||||
{memoryLabel ? (
|
||||
<span
|
||||
className="shrink-0"
|
||||
title={
|
||||
runtimeEntry?.pidSource === 'tmux_pane'
|
||||
? 'RSS source: tmux pane shell'
|
||||
: runtimeEntry?.pidSource
|
||||
? `PID source: ${runtimeEntry.pidSource}`
|
||||
: undefined
|
||||
}
|
||||
>
|
||||
<span className="shrink-0" title={memorySourceLabel}>
|
||||
{memoryLabel}
|
||||
</span>
|
||||
) : null}
|
||||
|
|
@ -330,20 +361,28 @@ export const MemberCard = ({
|
|||
</Badge>
|
||||
</span>
|
||||
) : spawnStatus === 'error' ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
|
||||
>
|
||||
{displayPresenceLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
|
||||
</Tooltip>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className="flex shrink-0 items-center gap-1">
|
||||
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
|
||||
>
|
||||
{displayPresenceLabel}
|
||||
</Badge>
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
|
||||
</Tooltip>
|
||||
{showCopyDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
className="size-auto rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200"
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
) : showRuntimeAdvisoryBadge ? (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
|
|||
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
||||
import { useStore } from '@renderer/store';
|
||||
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
||||
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
||||
import {
|
||||
buildMemberLaunchDiagnosticsPayload,
|
||||
getMemberLaunchDiagnosticsErrorMessage,
|
||||
hasMemberLaunchDiagnosticsDetails,
|
||||
hasMemberLaunchDiagnosticsError,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import {
|
||||
getRuntimeMemorySourceLabel,
|
||||
resolveMemberRuntimeSummary,
|
||||
} from '@renderer/utils/memberRuntimeSummary';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import {
|
||||
BarChart3,
|
||||
|
|
@ -22,6 +31,7 @@ import { buildMemberActivityEntries } from './memberActivityEntries';
|
|||
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||
import { MemberDetailStats } from './MemberDetailStats';
|
||||
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
import { MemberLogsTab } from './MemberLogsTab';
|
||||
import { MemberMessagesTab } from './MemberMessagesTab';
|
||||
import { MemberStatsTab } from './MemberStatsTab';
|
||||
|
|
@ -50,6 +60,7 @@ interface MemberDetailDialogProps {
|
|||
leadActivity?: LeadActivityState;
|
||||
spawnEntry?: MemberSpawnStatusEntry;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
runtimeRunId?: string | null;
|
||||
launchParams?: TeamLaunchParams;
|
||||
onClose: () => void;
|
||||
onSendMessage: () => void;
|
||||
|
|
@ -76,6 +87,7 @@ export const MemberDetailDialog = ({
|
|||
leadActivity,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
runtimeRunId,
|
||||
launchParams,
|
||||
onClose,
|
||||
onSendMessage,
|
||||
|
|
@ -128,10 +140,31 @@ export const MemberDetailDialog = ({
|
|||
: undefined,
|
||||
[launchParams, member, runtimeEntry, spawnEntry]
|
||||
);
|
||||
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
||||
const restartInFlight =
|
||||
spawnEntry?.launchState === 'starting' ||
|
||||
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
|
||||
spawnEntry?.launchState === 'runtime_pending_permission';
|
||||
const launchDiagnosticsPayload = useMemo(
|
||||
() =>
|
||||
member
|
||||
? buildMemberLaunchDiagnosticsPayload({
|
||||
teamName,
|
||||
runId: runtimeRunId,
|
||||
memberName: member.name,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
})
|
||||
: null,
|
||||
[member, runtimeEntry, runtimeRunId, spawnEntry, teamName]
|
||||
);
|
||||
const showCopyDiagnostics =
|
||||
launchDiagnosticsPayload != null &&
|
||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||
const launchErrorMessage = launchDiagnosticsPayload
|
||||
? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload)
|
||||
: undefined;
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || !member) {
|
||||
|
|
@ -251,10 +284,23 @@ export const MemberDetailDialog = ({
|
|||
<DialogFooter>
|
||||
{restartError ? (
|
||||
<div className="mr-auto text-xs text-red-400">{restartError}</div>
|
||||
) : launchErrorMessage ? (
|
||||
<div className="mr-auto flex min-w-0 items-center gap-2 text-xs text-red-400">
|
||||
<span className="min-w-0 truncate" title={launchErrorMessage}>
|
||||
{launchErrorMessage}
|
||||
</span>
|
||||
{launchDiagnosticsPayload && showCopyDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
label="Copy diagnostics"
|
||||
className="h-auto shrink-0 gap-1.5 px-2 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : runtimeEntry?.pid ? (
|
||||
<div className="mr-auto text-xs text-[var(--color-text-muted)]">
|
||||
PID {runtimeEntry.pid}
|
||||
{runtimeEntry.pidSource ? ` · ${runtimeEntry.pidSource}` : ''}
|
||||
{memorySourceLabel ? ` · ${memorySourceLabel}` : ''}
|
||||
</div>
|
||||
) : (
|
||||
<div className="mr-auto" />
|
||||
|
|
|
|||
|
|
@ -22,6 +22,12 @@ import {
|
|||
buildMemberLaunchPresentation,
|
||||
displayMemberName,
|
||||
} from '@renderer/utils/memberHelpers';
|
||||
import {
|
||||
buildMemberLaunchDiagnosticsPayload,
|
||||
getMemberLaunchDiagnosticsErrorMessage,
|
||||
hasMemberLaunchDiagnosticsDetails,
|
||||
hasMemberLaunchDiagnosticsError,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
import { ExternalLink } from 'lucide-react';
|
||||
import { useShallow } from 'zustand/react/shallow';
|
||||
|
|
@ -29,6 +35,7 @@ import { useShallow } from 'zustand/react/shallow';
|
|||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
||||
|
||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||
|
||||
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
||||
|
||||
|
|
@ -68,6 +75,7 @@ export const MemberHoverCard = ({
|
|||
memberSpawnSnapshot,
|
||||
memberSpawnStatuses,
|
||||
spawnEntry,
|
||||
runtimeRunId,
|
||||
runtimeEntry,
|
||||
leadActivity,
|
||||
} = useStore(
|
||||
|
|
@ -90,8 +98,11 @@ export const MemberHoverCard = ({
|
|||
spawnEntry: effectiveTeamName
|
||||
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
|
||||
: undefined,
|
||||
runtimeRunId: effectiveTeamName
|
||||
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.runId
|
||||
: undefined,
|
||||
runtimeEntry: effectiveTeamName
|
||||
? s.teamAgentRuntimeByTeam[effectiveTeamName]?.members[name]
|
||||
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.members[name]
|
||||
: undefined,
|
||||
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
|
||||
}))
|
||||
|
|
@ -143,6 +154,17 @@ export const MemberHoverCard = ({
|
|||
launchVisualState === 'stale_runtime'
|
||||
? (launchStatusLabel ?? presenceLabel)
|
||||
: presenceLabel;
|
||||
const launchDiagnosticsPayload = buildMemberLaunchDiagnosticsPayload({
|
||||
teamName: effectiveTeamName,
|
||||
runId: runtimeRunId ?? memberSpawnSnapshot?.runId ?? progress?.runId,
|
||||
memberName: member.name,
|
||||
spawnEntry,
|
||||
runtimeEntry,
|
||||
});
|
||||
const launchErrorMessage = getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload);
|
||||
const showCopyDiagnostics =
|
||||
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||
: null;
|
||||
|
|
@ -236,18 +258,33 @@ export const MemberHoverCard = ({
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* Open profile button */}
|
||||
<button
|
||||
type="button"
|
||||
className="flex w-full items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMemberProfile(member.name);
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
Open profile
|
||||
</button>
|
||||
{launchErrorMessage ? (
|
||||
<div className="flex items-center gap-2 rounded border border-red-500/25 bg-red-500/10 px-2 py-1.5 text-xs text-red-300">
|
||||
<span className="min-w-0 flex-1 truncate" title={launchErrorMessage}>
|
||||
{launchErrorMessage}
|
||||
</span>
|
||||
{showCopyDiagnostics ? (
|
||||
<MemberLaunchDiagnosticsButton
|
||||
payload={launchDiagnosticsPayload}
|
||||
className="h-auto shrink-0 rounded px-1.5 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="flex gap-1.5">
|
||||
<button
|
||||
type="button"
|
||||
className="flex flex-1 items-center justify-center gap-1.5 rounded border border-[var(--color-border)] px-3 py-1.5 text-xs text-[var(--color-text-secondary)] transition-colors hover:bg-[var(--color-surface-raised)] hover:text-[var(--color-text)]"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
openMemberProfile(member.name);
|
||||
}}
|
||||
>
|
||||
<ExternalLink size={12} />
|
||||
Open profile
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</HoverCardContent>
|
||||
</HoverCard>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,60 @@
|
|||
import { useState } from 'react';
|
||||
|
||||
import { Button } from '@renderer/components/ui/button';
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||
import {
|
||||
formatMemberLaunchDiagnosticsPayload,
|
||||
type MemberLaunchDiagnosticsPayload,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import { Check, ClipboardList } from 'lucide-react';
|
||||
|
||||
interface MemberLaunchDiagnosticsButtonProps {
|
||||
payload: MemberLaunchDiagnosticsPayload;
|
||||
label?: string;
|
||||
className?: string;
|
||||
size?: 'icon' | 'sm';
|
||||
}
|
||||
|
||||
export const MemberLaunchDiagnosticsButton = ({
|
||||
payload,
|
||||
label,
|
||||
className,
|
||||
size = label ? 'sm' : 'icon',
|
||||
}: MemberLaunchDiagnosticsButtonProps): React.JSX.Element => {
|
||||
const [copied, setCopied] = useState(false);
|
||||
|
||||
const copyDiagnostics = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
try {
|
||||
await navigator.clipboard.writeText(formatMemberLaunchDiagnosticsPayload(payload));
|
||||
setCopied(true);
|
||||
window.setTimeout(() => setCopied(false), 1500);
|
||||
} catch {
|
||||
setCopied(false);
|
||||
}
|
||||
};
|
||||
|
||||
const icon = copied ? <Check size={13} /> : <ClipboardList size={13} />;
|
||||
const tooltip = copied ? 'Diagnostics copied' : 'Copy diagnostics';
|
||||
|
||||
return (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={size}
|
||||
className={className}
|
||||
title={tooltip}
|
||||
aria-label={tooltip}
|
||||
onClick={copyDiagnostics}
|
||||
>
|
||||
{icon}
|
||||
{label ? <span>{label}</span> : null}
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="bottom">{tooltip}</TooltipContent>
|
||||
</Tooltip>
|
||||
);
|
||||
};
|
||||
|
|
@ -23,6 +23,7 @@ interface MemberListProps {
|
|||
pendingRepliesByMember?: Record<string, number>;
|
||||
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
|
||||
memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>;
|
||||
runtimeRunId?: string | null;
|
||||
isLaunchSettling?: boolean;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
|
|
@ -192,20 +193,34 @@ function areMemberRuntimeEntriesEquivalent(
|
|||
if (left.size !== right.size) return false;
|
||||
for (const [key, leftEntry] of left) {
|
||||
const rightEntry = right.get(key);
|
||||
const leftDiagnostics = leftEntry.diagnostics ?? [];
|
||||
const rightDiagnostics = rightEntry?.diagnostics ?? [];
|
||||
if (
|
||||
leftEntry.memberName !== rightEntry?.memberName ||
|
||||
leftEntry.alive !== rightEntry?.alive ||
|
||||
leftEntry.restartable !== rightEntry?.restartable ||
|
||||
leftEntry.backendType !== rightEntry?.backendType ||
|
||||
leftEntry.providerId !== rightEntry?.providerId ||
|
||||
leftEntry.providerBackendId !== rightEntry?.providerBackendId ||
|
||||
leftEntry.laneId !== rightEntry?.laneId ||
|
||||
leftEntry.laneKind !== rightEntry?.laneKind ||
|
||||
leftEntry.pid !== rightEntry?.pid ||
|
||||
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
|
||||
leftEntry.rssBytes !== rightEntry?.rssBytes ||
|
||||
leftEntry.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.runtimeLastSeenAt !== rightEntry?.runtimeLastSeenAt ||
|
||||
leftEntry.historicalBootstrapConfirmed !== rightEntry?.historicalBootstrapConfirmed ||
|
||||
leftDiagnostics.length !== rightDiagnostics.length ||
|
||||
!leftDiagnostics.every((value, index) => value === rightDiagnostics[index])
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -224,6 +239,7 @@ function areMemberListPropsEqual(
|
|||
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
|
||||
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
|
||||
areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) &&
|
||||
prev.runtimeRunId === next.runtimeRunId &&
|
||||
prev.isLaunchSettling === next.isLaunchSettling &&
|
||||
prev.isTeamAlive === next.isTeamAlive &&
|
||||
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
||||
|
|
@ -239,6 +255,7 @@ export const MemberList = memo(function MemberList({
|
|||
pendingRepliesByMember,
|
||||
memberSpawnStatuses,
|
||||
memberRuntimeEntries,
|
||||
runtimeRunId,
|
||||
isLaunchSettling,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
|
|
@ -342,7 +359,9 @@ export const MemberList = memo(function MemberList({
|
|||
isRemoved ? undefined : runtimeEntry
|
||||
)}
|
||||
runtimeEntry={isRemoved ? undefined : runtimeEntry}
|
||||
runtimeRunId={isRemoved ? undefined : runtimeRunId}
|
||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||
spawnEntry={isRemoved ? undefined : spawnEntry}
|
||||
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
|
||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import { MembersEditorSection } from './MembersEditorSection';
|
|||
|
||||
import type { MemberDraft } from './membersEditorTypes';
|
||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
|
||||
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||
|
||||
interface TeamRosterEditorSectionProps {
|
||||
members: MemberDraft[];
|
||||
|
|
@ -31,13 +31,10 @@ interface TeamRosterEditorSectionProps {
|
|||
providerId: TeamProviderId;
|
||||
model: string;
|
||||
effort?: EffortLevel;
|
||||
fastMode?: TeamFastMode;
|
||||
providerFastModeDefault?: boolean;
|
||||
limitContext: boolean;
|
||||
onProviderChange: (providerId: TeamProviderId) => void;
|
||||
onModelChange: (model: string) => void;
|
||||
onEffortChange: (effort: string) => void;
|
||||
onFastModeChange?: (fastMode: TeamFastMode) => void;
|
||||
onLimitContextChange: (value: boolean) => void;
|
||||
syncModelsWithTeammates: boolean;
|
||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||
|
|
@ -45,7 +42,6 @@ interface TeamRosterEditorSectionProps {
|
|||
headerBottom?: React.ReactNode;
|
||||
softDeleteMembers?: boolean;
|
||||
leadWarningText?: string | null;
|
||||
leadFastModeNotice?: string | null;
|
||||
memberWarningById?: Record<string, string | null | undefined>;
|
||||
disableGeminiOption?: boolean;
|
||||
leadModelIssueText?: string | null;
|
||||
|
|
@ -79,13 +75,10 @@ export const TeamRosterEditorSection = ({
|
|||
providerId,
|
||||
model,
|
||||
effort,
|
||||
fastMode,
|
||||
providerFastModeDefault,
|
||||
limitContext,
|
||||
onProviderChange,
|
||||
onModelChange,
|
||||
onEffortChange,
|
||||
onFastModeChange,
|
||||
onLimitContextChange,
|
||||
syncModelsWithTeammates,
|
||||
onSyncModelsWithTeammatesChange,
|
||||
|
|
@ -93,7 +86,6 @@ export const TeamRosterEditorSection = ({
|
|||
headerBottom,
|
||||
softDeleteMembers = false,
|
||||
leadWarningText,
|
||||
leadFastModeNotice,
|
||||
memberWarningById,
|
||||
disableGeminiOption = false,
|
||||
leadModelIssueText,
|
||||
|
|
@ -138,18 +130,14 @@ export const TeamRosterEditorSection = ({
|
|||
providerId={providerId}
|
||||
model={model}
|
||||
effort={effort}
|
||||
fastMode={fastMode}
|
||||
providerFastModeDefault={providerFastModeDefault}
|
||||
limitContext={limitContext}
|
||||
onProviderChange={onProviderChange}
|
||||
onModelChange={onModelChange}
|
||||
onEffortChange={onEffortChange}
|
||||
onFastModeChange={onFastModeChange}
|
||||
onLimitContextChange={onLimitContextChange}
|
||||
syncModelsWithTeammates={syncModelsWithTeammates}
|
||||
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
||||
warningText={leadWarningText}
|
||||
fastModeNotice={leadFastModeNotice}
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
modelIssueText={leadModelIssueText}
|
||||
/>
|
||||
|
|
|
|||
|
|
@ -63,6 +63,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean
|
|||
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
|
||||
}
|
||||
|
||||
function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolean {
|
||||
return entry.runtimeAlive === true && entry.livenessKind === 'runtime_process';
|
||||
}
|
||||
|
||||
function shouldPreferSnapshotEntryOverLive(
|
||||
liveEntry: MemberSpawnStatusEntry | undefined,
|
||||
snapshotEntry: MemberSpawnStatusEntry | undefined,
|
||||
|
|
@ -127,11 +131,12 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
|||
heartbeatConfirmedCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
entry.launchState === 'runtime_pending_bootstrap' ||
|
||||
entry.launchState === 'runtime_pending_permission'
|
||||
) {
|
||||
if (entry.runtimeAlive === true && entry.livenessKind !== 'shell_only') {
|
||||
if (entry.launchState === 'runtime_pending_permission') {
|
||||
pendingSpawnCount += 1;
|
||||
continue;
|
||||
}
|
||||
if (entry.launchState === 'runtime_pending_bootstrap') {
|
||||
if (isStrongRuntimeProcessSpawnEntry(entry)) {
|
||||
processOnlyAliveCount += 1;
|
||||
} else {
|
||||
pendingSpawnCount += 1;
|
||||
|
|
@ -196,15 +201,12 @@ export function getLaunchJoinMilestonesFromMembers({
|
|||
});
|
||||
|
||||
if (snapshotSummary) {
|
||||
const snapshotProcessOnlyAliveCount = snapshotSummary.runtimeProcessPendingCount ?? 0;
|
||||
const snapshotMilestones = {
|
||||
expectedTeammateCount,
|
||||
heartbeatConfirmedCount: snapshotSummary.confirmedCount,
|
||||
processOnlyAliveCount:
|
||||
snapshotSummary.runtimeProcessPendingCount ?? snapshotSummary.runtimeAlivePendingCount,
|
||||
pendingSpawnCount: Math.max(
|
||||
0,
|
||||
snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount
|
||||
),
|
||||
processOnlyAliveCount: snapshotProcessOnlyAliveCount,
|
||||
pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount),
|
||||
failedSpawnCount: snapshotSummary.failedCount,
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -374,6 +374,19 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
set,
|
||||
get
|
||||
) => {
|
||||
const addMatchingReviewPathAliases = (
|
||||
aliases: Set<string>,
|
||||
filePath: string,
|
||||
canonicalFilePath: string,
|
||||
record: Record<string, unknown>
|
||||
): void => {
|
||||
for (const key of Object.keys(record)) {
|
||||
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
|
||||
aliases.add(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const buildResolvedFileInvalidation = (
|
||||
s: ChangeReviewSlice,
|
||||
filePath: string
|
||||
|
|
@ -388,17 +401,10 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
const existing = findReviewFileByPath(s.activeChangeSet?.files, filePath);
|
||||
const canonicalFilePath = existing?.filePath ?? filePath;
|
||||
const aliases = new Set([filePath, canonicalFilePath]);
|
||||
const addMatchingAliases = (record: Record<string, unknown>): void => {
|
||||
for (const key of Object.keys(record)) {
|
||||
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
|
||||
aliases.add(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
addMatchingAliases(s.fileChunkCounts);
|
||||
addMatchingAliases(s.fileContents);
|
||||
addMatchingAliases(s.fileContentsLoading);
|
||||
addMatchingAliases(s.fileContentVersionByPath);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentsLoading);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentVersionByPath);
|
||||
const nextFileChunkCounts = { ...s.fileChunkCounts };
|
||||
for (const alias of aliases) delete nextFileChunkCounts[alias];
|
||||
|
||||
|
|
@ -1461,18 +1467,21 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
|||
await api.review.saveEditedFile(canonicalFilePath, content, projectPath);
|
||||
set((s) => {
|
||||
const aliases = new Set([filePath, canonicalFilePath]);
|
||||
const addMatchingAliases = (record: Record<string, unknown>): void => {
|
||||
for (const key of Object.keys(record)) {
|
||||
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
|
||||
aliases.add(key);
|
||||
}
|
||||
}
|
||||
};
|
||||
addMatchingAliases(s.editedContents);
|
||||
addMatchingAliases(s.fileChunkCounts);
|
||||
addMatchingAliases(s.hunkContextHashesByFile);
|
||||
addMatchingAliases(s.reviewExternalChangesByFile);
|
||||
addMatchingAliases(s.fileContents);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.editedContents);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
|
||||
addMatchingReviewPathAliases(
|
||||
aliases,
|
||||
filePath,
|
||||
canonicalFilePath,
|
||||
s.hunkContextHashesByFile
|
||||
);
|
||||
addMatchingReviewPathAliases(
|
||||
aliases,
|
||||
filePath,
|
||||
canonicalFilePath,
|
||||
s.reviewExternalChangesByFile
|
||||
);
|
||||
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
|
||||
|
||||
const nextEdited = { ...s.editedContents };
|
||||
for (const alias of aliases) delete nextEdited[alias];
|
||||
|
|
|
|||
|
|
@ -810,20 +810,34 @@ function areTeamAgentRuntimeEntriesEqual(
|
|||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return left === right;
|
||||
const leftDiagnostics = left.diagnostics ?? [];
|
||||
const rightDiagnostics = right.diagnostics ?? [];
|
||||
return (
|
||||
left.memberName === right.memberName &&
|
||||
left.alive === right.alive &&
|
||||
left.restartable === right.restartable &&
|
||||
left.backendType === right.backendType &&
|
||||
left.providerId === right.providerId &&
|
||||
left.providerBackendId === right.providerBackendId &&
|
||||
left.laneId === right.laneId &&
|
||||
left.laneKind === right.laneKind &&
|
||||
left.pid === right.pid &&
|
||||
left.runtimeModel === right.runtimeModel &&
|
||||
left.rssBytes === right.rssBytes &&
|
||||
left.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.runtimeLastSeenAt === right.runtimeLastSeenAt &&
|
||||
left.historicalBootstrapConfirmed === right.historicalBootstrapConfirmed &&
|
||||
leftDiagnostics.length === rightDiagnostics.length &&
|
||||
leftDiagnostics.every((value, index) => value === rightDiagnostics[index])
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -656,12 +656,6 @@ export function buildMemberLaunchPresentation({
|
|||
runtimeEntry?.livenessKind === 'not_found'
|
||||
) {
|
||||
launchVisualState = 'stale_runtime';
|
||||
} else if (
|
||||
spawnLaunchState === 'runtime_pending_bootstrap' &&
|
||||
(runtimeEntry?.livenessKind === 'runtime_process' ||
|
||||
(spawnStatus === 'online' && spawnRuntimeAlive === true))
|
||||
) {
|
||||
launchVisualState = 'runtime_pending';
|
||||
} else if (
|
||||
isLaunchStillStarting(
|
||||
spawnStatus,
|
||||
|
|
@ -671,6 +665,12 @@ export function buildMemberLaunchPresentation({
|
|||
)
|
||||
) {
|
||||
launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
|
||||
} else if (
|
||||
spawnLaunchState === 'runtime_pending_bootstrap' &&
|
||||
(runtimeEntry?.livenessKind === 'runtime_process' ||
|
||||
(spawnStatus === 'online' && spawnRuntimeAlive === true))
|
||||
) {
|
||||
launchVisualState = 'runtime_pending';
|
||||
} else if (
|
||||
isLaunchSettling &&
|
||||
spawnStatus === 'online' &&
|
||||
|
|
@ -681,15 +681,19 @@ export function buildMemberLaunchPresentation({
|
|||
}
|
||||
|
||||
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
|
||||
const displayPresenceLabel =
|
||||
const shouldShowLaunchStatusAsPresence =
|
||||
launchVisualState === 'permission_pending' ||
|
||||
launchVisualState === 'runtime_pending' ||
|
||||
launchVisualState === 'shell_only' ||
|
||||
launchVisualState === 'runtime_candidate' ||
|
||||
launchVisualState === 'registered_only' ||
|
||||
launchVisualState === 'stale_runtime'
|
||||
? (launchStatusLabel ?? presenceLabel)
|
||||
: presenceLabel;
|
||||
launchVisualState === 'stale_runtime';
|
||||
const displayPresenceLabel =
|
||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||
? runtimeAdvisoryLabel
|
||||
: shouldShowLaunchStatusAsPresence
|
||||
? (launchStatusLabel ?? presenceLabel)
|
||||
: presenceLabel;
|
||||
const spawnBadgeLabel =
|
||||
spawnStatus && spawnStatus !== 'online'
|
||||
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
||||
|
|
|
|||
189
src/renderer/utils/memberLaunchDiagnostics.ts
Normal file
189
src/renderer/utils/memberLaunchDiagnostics.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import type {
|
||||
MemberLaunchState,
|
||||
MemberSpawnLivenessSource,
|
||||
MemberSpawnStatus,
|
||||
MemberSpawnStatusEntry,
|
||||
TeamAgentRuntimeDiagnosticSeverity,
|
||||
TeamAgentRuntimeEntry,
|
||||
TeamAgentRuntimeLivenessKind,
|
||||
TeamAgentRuntimePidSource,
|
||||
} from '@shared/types';
|
||||
|
||||
export interface MemberLaunchDiagnosticsPayload {
|
||||
teamName?: string;
|
||||
runId?: string;
|
||||
memberName: string;
|
||||
launchState?: MemberLaunchState;
|
||||
spawnStatus?: MemberSpawnStatus;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
livenessSource?: MemberSpawnLivenessSource;
|
||||
pid?: number;
|
||||
pidSource?: TeamAgentRuntimePidSource;
|
||||
paneId?: string;
|
||||
panePid?: number;
|
||||
paneCurrentCommand?: string;
|
||||
processCommand?: string;
|
||||
runtimePid?: number;
|
||||
runtimeSessionId?: string;
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
diagnostics?: string[];
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
const MAX_DIAGNOSTIC_STRING_LENGTH = 500;
|
||||
const MAX_DIAGNOSTIC_ITEMS = 20;
|
||||
const SECRET_FLAG_PATTERN =
|
||||
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
|
||||
|
||||
function boundedString(
|
||||
value: string | undefined,
|
||||
maxLength = MAX_DIAGNOSTIC_STRING_LENGTH
|
||||
): string | undefined {
|
||||
const trimmed = value?.replace(/\s+/g, ' ').trim();
|
||||
if (!trimmed) return undefined;
|
||||
const redacted = trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]');
|
||||
return redacted.length > maxLength
|
||||
? `${redacted.slice(0, Math.max(0, maxLength - 3))}...`
|
||||
: redacted;
|
||||
}
|
||||
|
||||
function boundedNumber(value: number | undefined): number | undefined {
|
||||
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||
? Math.trunc(value)
|
||||
: undefined;
|
||||
}
|
||||
|
||||
function uniqueDiagnostics(
|
||||
...groups: Array<readonly (string | undefined)[] | undefined>
|
||||
): string[] | undefined {
|
||||
const seen = new Set<string>();
|
||||
const diagnostics: string[] = [];
|
||||
for (const group of groups) {
|
||||
for (const item of group ?? []) {
|
||||
const normalized = boundedString(item);
|
||||
if (!normalized || seen.has(normalized)) continue;
|
||||
seen.add(normalized);
|
||||
diagnostics.push(normalized);
|
||||
if (diagnostics.length >= MAX_DIAGNOSTIC_ITEMS) {
|
||||
return diagnostics;
|
||||
}
|
||||
}
|
||||
}
|
||||
return diagnostics.length > 0 ? diagnostics : undefined;
|
||||
}
|
||||
|
||||
export function buildMemberLaunchDiagnosticsPayload(params: {
|
||||
teamName?: string | null;
|
||||
runId?: string | null;
|
||||
memberName: string;
|
||||
spawnStatus?: MemberSpawnStatus;
|
||||
launchState?: MemberLaunchState;
|
||||
livenessSource?: MemberSpawnLivenessSource;
|
||||
spawnEntry?: MemberSpawnStatusEntry;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
}): MemberLaunchDiagnosticsPayload {
|
||||
const spawnEntry = params.spawnEntry;
|
||||
const runtimeEntry = params.runtimeEntry;
|
||||
const runtimeDiagnostic =
|
||||
boundedString(spawnEntry?.runtimeDiagnostic) ??
|
||||
boundedString(runtimeEntry?.runtimeDiagnostic) ??
|
||||
boundedString(spawnEntry?.hardFailureReason) ??
|
||||
boundedString(spawnEntry?.error);
|
||||
const diagnostics = uniqueDiagnostics(
|
||||
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
|
||||
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
|
||||
spawnEntry?.error ? [spawnEntry.error] : undefined,
|
||||
runtimeEntry?.diagnostics
|
||||
);
|
||||
const runId = boundedString(params.runId ?? undefined);
|
||||
|
||||
return {
|
||||
...(params.teamName ? { teamName: params.teamName } : {}),
|
||||
...(runId ? { runId } : {}),
|
||||
memberName: params.memberName,
|
||||
...((spawnEntry?.launchState ?? params.launchState)
|
||||
? { launchState: spawnEntry?.launchState ?? params.launchState }
|
||||
: {}),
|
||||
...((spawnEntry?.status ?? params.spawnStatus)
|
||||
? { spawnStatus: spawnEntry?.status ?? params.spawnStatus }
|
||||
: {}),
|
||||
...((spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind)
|
||||
? { livenessKind: spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind }
|
||||
: {}),
|
||||
...((spawnEntry?.livenessSource ?? params.livenessSource)
|
||||
? { livenessSource: spawnEntry?.livenessSource ?? params.livenessSource }
|
||||
: {}),
|
||||
...(boundedNumber(runtimeEntry?.pid) ? { pid: boundedNumber(runtimeEntry?.pid) } : {}),
|
||||
...(runtimeEntry?.pidSource ? { pidSource: runtimeEntry.pidSource } : {}),
|
||||
...(boundedString(runtimeEntry?.paneId) ? { paneId: boundedString(runtimeEntry?.paneId) } : {}),
|
||||
...(boundedNumber(runtimeEntry?.panePid)
|
||||
? { panePid: boundedNumber(runtimeEntry?.panePid) }
|
||||
: {}),
|
||||
...(boundedString(runtimeEntry?.paneCurrentCommand)
|
||||
? { paneCurrentCommand: boundedString(runtimeEntry?.paneCurrentCommand) }
|
||||
: {}),
|
||||
...(boundedString(runtimeEntry?.processCommand)
|
||||
? { processCommand: boundedString(runtimeEntry?.processCommand) }
|
||||
: {}),
|
||||
...(boundedNumber(runtimeEntry?.runtimePid)
|
||||
? { runtimePid: boundedNumber(runtimeEntry?.runtimePid) }
|
||||
: {}),
|
||||
...(boundedString(runtimeEntry?.runtimeSessionId)
|
||||
? { runtimeSessionId: boundedString(runtimeEntry?.runtimeSessionId) }
|
||||
: {}),
|
||||
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||
...((spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity)
|
||||
? {
|
||||
runtimeDiagnosticSeverity:
|
||||
spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity,
|
||||
}
|
||||
: {}),
|
||||
...(diagnostics ? { diagnostics } : {}),
|
||||
...(boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt)
|
||||
? { updatedAt: boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt) }
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
export function hasMemberLaunchDiagnosticsDetails(
|
||||
payload: MemberLaunchDiagnosticsPayload
|
||||
): boolean {
|
||||
const weakLiveness =
|
||||
payload.livenessKind === 'runtime_process_candidate' ||
|
||||
payload.livenessKind === 'permission_blocked' ||
|
||||
payload.livenessKind === 'shell_only' ||
|
||||
payload.livenessKind === 'registered_only' ||
|
||||
payload.livenessKind === 'stale_metadata' ||
|
||||
payload.livenessKind === 'not_found';
|
||||
return Boolean(
|
||||
(payload.launchState && payload.launchState !== 'confirmed_alive') ||
|
||||
(payload.spawnStatus && payload.spawnStatus !== 'online') ||
|
||||
weakLiveness ||
|
||||
payload.runtimeDiagnostic ||
|
||||
payload.diagnostics?.length
|
||||
);
|
||||
}
|
||||
|
||||
export function hasMemberLaunchDiagnosticsError(payload: MemberLaunchDiagnosticsPayload): boolean {
|
||||
return Boolean(
|
||||
payload.spawnStatus === 'error' ||
|
||||
payload.launchState === 'failed_to_start' ||
|
||||
payload.runtimeDiagnosticSeverity === 'error'
|
||||
);
|
||||
}
|
||||
|
||||
export function getMemberLaunchDiagnosticsErrorMessage(
|
||||
payload: MemberLaunchDiagnosticsPayload
|
||||
): string | undefined {
|
||||
if (!hasMemberLaunchDiagnosticsError(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
return payload.runtimeDiagnostic ?? payload.diagnostics?.[0] ?? 'Launch failed';
|
||||
}
|
||||
|
||||
export function formatMemberLaunchDiagnosticsPayload(
|
||||
payload: MemberLaunchDiagnosticsPayload
|
||||
): string {
|
||||
return JSON.stringify(payload, null, 2);
|
||||
}
|
||||
|
|
@ -40,6 +40,37 @@ function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined):
|
|||
);
|
||||
}
|
||||
|
||||
export function getRuntimeMemorySourceLabel(
|
||||
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||
): string | undefined {
|
||||
if (!runtimeEntry?.pidSource) {
|
||||
return undefined;
|
||||
}
|
||||
if (runtimeEntry.pidSource === 'tmux_pane') {
|
||||
return 'RSS source: tmux pane shell';
|
||||
}
|
||||
if (
|
||||
runtimeEntry.providerId === 'opencode' &&
|
||||
runtimeEntry.restartable === false &&
|
||||
runtimeEntry.pidSource === 'opencode_bridge'
|
||||
) {
|
||||
return 'RSS source: shared OpenCode host';
|
||||
}
|
||||
if (runtimeEntry.pidSource === 'tmux_child' || runtimeEntry.pidSource === 'agent_process_table') {
|
||||
return 'RSS source: runtime process';
|
||||
}
|
||||
if (runtimeEntry.pidSource === 'lead_process') {
|
||||
return 'RSS source: lead process';
|
||||
}
|
||||
if (runtimeEntry.pidSource === 'runtime_bootstrap') {
|
||||
return 'RSS source: runtime bootstrap process';
|
||||
}
|
||||
if (runtimeEntry.pidSource === 'persisted_metadata') {
|
||||
return 'RSS source: persisted runtime metadata';
|
||||
}
|
||||
return `PID source: ${runtimeEntry.pidSource}`;
|
||||
}
|
||||
|
||||
export function resolveMemberRuntimeSummary(
|
||||
member: ResolvedTeamMember,
|
||||
launchParams: TeamLaunchParams | undefined,
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ export function buildPendingRuntimeSummaryCopy(input: {
|
|||
confirmedCount?: number | null;
|
||||
expectedMemberCount?: number | null;
|
||||
memberCount?: number | null;
|
||||
runtimeAlivePendingCount?: number | null;
|
||||
runtimeProcessPendingCount?: number | null;
|
||||
includePeriod?: boolean;
|
||||
}): string {
|
||||
const pendingCount = input.runtimeAlivePendingCount ?? 0;
|
||||
const pendingCount = input.runtimeProcessPendingCount ?? 0;
|
||||
if (pendingCount <= 0) {
|
||||
return input.includePeriod
|
||||
? 'Last launch is still reconciling.'
|
||||
|
|
|
|||
|
|
@ -32,6 +32,17 @@ interface FailedSpawnDetail {
|
|||
reason: string | null;
|
||||
}
|
||||
|
||||
type PendingDiagnosticBucket =
|
||||
| 'shellOnly'
|
||||
| 'runtimeProcess'
|
||||
| 'runtimeCandidate'
|
||||
| 'permission'
|
||||
| 'noRuntime';
|
||||
|
||||
type PendingDiagnosticNameGroups = Record<PendingDiagnosticBucket, string[]>;
|
||||
|
||||
const MAX_PENDING_DIAGNOSTIC_NAMES = 4;
|
||||
|
||||
function parseStatusUpdatedAtMs(value: string | undefined): number | null {
|
||||
if (!value) {
|
||||
return null;
|
||||
|
|
@ -126,25 +137,130 @@ function buildAwaitingPermissionPhrase(count: number): string {
|
|||
: `${count} teammates awaiting permission approval`;
|
||||
}
|
||||
|
||||
function buildPendingDiagnosticPhrase(
|
||||
summary: MemberSpawnStatusesSnapshot['summary'] | undefined,
|
||||
fallbackJoiningPhrase: string
|
||||
): string {
|
||||
function getMemberNamesFromSpawnSources(params: {
|
||||
memberSpawnStatuses: MemberSpawnStatusCollection;
|
||||
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
|
||||
}): string[] {
|
||||
const names = new Set<string>();
|
||||
if (params.memberSpawnStatuses instanceof Map) {
|
||||
for (const name of params.memberSpawnStatuses.keys()) {
|
||||
names.add(name);
|
||||
}
|
||||
} else if (params.memberSpawnStatuses) {
|
||||
for (const name of Object.keys(params.memberSpawnStatuses)) {
|
||||
names.add(name);
|
||||
}
|
||||
}
|
||||
for (const name of Object.keys(params.memberSpawnSnapshotStatuses ?? {})) {
|
||||
names.add(name);
|
||||
}
|
||||
return [...names].sort((left, right) => left.localeCompare(right));
|
||||
}
|
||||
|
||||
function getPendingDiagnosticNameGroups(params: {
|
||||
memberSpawnStatuses: MemberSpawnStatusCollection;
|
||||
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
|
||||
memberSpawnSnapshotUpdatedAt?: string;
|
||||
}): PendingDiagnosticNameGroups {
|
||||
const groups: PendingDiagnosticNameGroups = {
|
||||
shellOnly: [],
|
||||
runtimeProcess: [],
|
||||
runtimeCandidate: [],
|
||||
permission: [],
|
||||
noRuntime: [],
|
||||
};
|
||||
|
||||
for (const name of getMemberNamesFromSpawnSources(params)) {
|
||||
const liveEntry =
|
||||
params.memberSpawnStatuses instanceof Map
|
||||
? params.memberSpawnStatuses.get(name)
|
||||
: params.memberSpawnStatuses?.[name];
|
||||
const snapshotEntry = params.memberSpawnSnapshotStatuses?.[name];
|
||||
const entry = getPreferredSpawnEntry({
|
||||
liveEntry,
|
||||
snapshotEntry,
|
||||
snapshotUpdatedAt: params.memberSpawnSnapshotUpdatedAt,
|
||||
});
|
||||
if (!entry || entry.launchState === 'confirmed_alive' || isFailedSpawnEntry(entry)) {
|
||||
continue;
|
||||
}
|
||||
if (
|
||||
entry.launchState === 'runtime_pending_permission' ||
|
||||
(entry.pendingPermissionRequestIds?.length ?? 0) > 0
|
||||
) {
|
||||
groups.permission.push(name);
|
||||
continue;
|
||||
}
|
||||
if (entry.livenessKind === 'shell_only') {
|
||||
groups.shellOnly.push(name);
|
||||
} else if (entry.livenessKind === 'runtime_process') {
|
||||
groups.runtimeProcess.push(name);
|
||||
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
||||
groups.runtimeCandidate.push(name);
|
||||
} else if (
|
||||
entry.livenessKind === 'not_found' ||
|
||||
entry.livenessKind === 'stale_metadata' ||
|
||||
entry.livenessKind === 'registered_only'
|
||||
) {
|
||||
groups.noRuntime.push(name);
|
||||
}
|
||||
}
|
||||
|
||||
return groups;
|
||||
}
|
||||
|
||||
function formatNamedPendingDiagnostic(label: string, names: readonly string[]): string | null {
|
||||
if (names.length === 0) {
|
||||
return null;
|
||||
}
|
||||
const listedNames = names.slice(0, MAX_PENDING_DIAGNOSTIC_NAMES).join(', ');
|
||||
const remainingCount = names.length - Math.min(names.length, MAX_PENDING_DIAGNOSTIC_NAMES);
|
||||
return `${label}: ${listedNames}${remainingCount > 0 ? `, +${remainingCount} more` : ''}`;
|
||||
}
|
||||
|
||||
function formatCountPendingDiagnostic(count: number | undefined, label: string): string | null {
|
||||
return count && count > 0 ? `${count} ${label}` : null;
|
||||
}
|
||||
|
||||
function buildPendingDiagnosticPhrase({
|
||||
summary,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshotStatuses,
|
||||
memberSpawnSnapshotUpdatedAt,
|
||||
fallbackJoiningPhrase,
|
||||
}: {
|
||||
summary: MemberSpawnStatusesSnapshot['summary'] | undefined;
|
||||
memberSpawnStatuses: MemberSpawnStatusCollection;
|
||||
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
|
||||
memberSpawnSnapshotUpdatedAt?: string;
|
||||
fallbackJoiningPhrase: string;
|
||||
}): string {
|
||||
const groups = getPendingDiagnosticNameGroups({
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshotStatuses,
|
||||
memberSpawnSnapshotUpdatedAt,
|
||||
});
|
||||
const namedParts = [
|
||||
formatNamedPendingDiagnostic('Shell-only', groups.shellOnly),
|
||||
formatNamedPendingDiagnostic('Waiting for bootstrap', groups.runtimeProcess),
|
||||
formatNamedPendingDiagnostic('Process candidates', groups.runtimeCandidate),
|
||||
formatNamedPendingDiagnostic('Awaiting permission', groups.permission),
|
||||
formatNamedPendingDiagnostic('No runtime found', groups.noRuntime),
|
||||
].filter(Boolean);
|
||||
if (namedParts.length > 0) {
|
||||
return namedParts.join(', ');
|
||||
}
|
||||
if (!summary) {
|
||||
return fallbackJoiningPhrase;
|
||||
}
|
||||
const parts = [
|
||||
summary.shellOnlyPendingCount ? `${summary.shellOnlyPendingCount} shell-only` : '',
|
||||
summary.runtimeProcessPendingCount
|
||||
? `${summary.runtimeProcessPendingCount} waiting for bootstrap`
|
||||
: '',
|
||||
summary.runtimeCandidatePendingCount
|
||||
? `${summary.runtimeCandidatePendingCount} process candidates`
|
||||
: '',
|
||||
summary.permissionPendingCount ? `${summary.permissionPendingCount} awaiting permission` : '',
|
||||
summary.noRuntimePendingCount ? `${summary.noRuntimePendingCount} no runtime found` : '',
|
||||
const countParts = [
|
||||
formatCountPendingDiagnostic(summary.shellOnlyPendingCount, 'shell-only'),
|
||||
formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'),
|
||||
formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'process candidates'),
|
||||
formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'),
|
||||
formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'no runtime found'),
|
||||
].filter(Boolean);
|
||||
return parts.length > 0 ? parts.join(', ') : fallbackJoiningPhrase;
|
||||
return countParts.length > 0 ? countParts.join(', ') : fallbackJoiningPhrase;
|
||||
}
|
||||
|
||||
const ACTIVE_PROVISIONING_STATES = new Set([
|
||||
|
|
@ -415,7 +531,13 @@ export function buildTeamProvisioningPresentation({
|
|||
permissionBlockedCount === remainingJoinCount;
|
||||
const pendingDetailPhrase = pendingMembersAwaitApproval
|
||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, joiningPhrase);
|
||||
: buildPendingDiagnosticPhrase({
|
||||
summary: memberSpawnSnapshot?.summary,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||
fallbackJoiningPhrase: joiningPhrase,
|
||||
});
|
||||
const readyCompactDetail =
|
||||
failedSpawnCount > 0
|
||||
? (failedSpawnCompactDetail ??
|
||||
|
|
@ -492,7 +614,13 @@ export function buildTeamProvisioningPresentation({
|
|||
permissionBlockedCount > 0 &&
|
||||
permissionBlockedCount === remainingJoinCount
|
||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, activeJoiningPhrase);
|
||||
: buildPendingDiagnosticPhrase({
|
||||
summary: memberSpawnSnapshot?.summary,
|
||||
memberSpawnStatuses,
|
||||
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||
fallbackJoiningPhrase: activeJoiningPhrase,
|
||||
});
|
||||
return {
|
||||
progress,
|
||||
isActive: true,
|
||||
|
|
|
|||
|
|
@ -1043,6 +1043,8 @@ export interface TeamAgentRuntimeEntry {
|
|||
runtimeSessionId?: string;
|
||||
runtimeLeaseExpiresAt?: string;
|
||||
runtimeLastSeenAt?: string;
|
||||
/** True when a previous/persisted launch confirmed bootstrap, separate from current live liveness. */
|
||||
historicalBootstrapConfirmed?: boolean;
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||
diagnostics?: string[];
|
||||
|
|
|
|||
|
|
@ -24,13 +24,28 @@ export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
|
|||
}
|
||||
}
|
||||
|
||||
const explicit = normalizeReviewState(task.reviewState);
|
||||
if (explicit !== 'none') {
|
||||
const fallbackStatus = typeof task.status === 'string' ? task.status : null;
|
||||
const normalizeFallback = (value: unknown): TeamReviewState | null => {
|
||||
const explicit = normalizeReviewState(value);
|
||||
if (explicit === 'none') return null;
|
||||
|
||||
if (fallbackStatus === 'in_progress' || fallbackStatus === 'deleted') {
|
||||
return 'none';
|
||||
}
|
||||
if (fallbackStatus === 'pending') {
|
||||
return explicit === 'needsFix' ? 'needsFix' : 'none';
|
||||
}
|
||||
if (fallbackStatus === 'completed') {
|
||||
return explicit === 'review' || explicit === 'approved' ? explicit : 'none';
|
||||
}
|
||||
return explicit;
|
||||
}
|
||||
};
|
||||
|
||||
const explicit = normalizeFallback(task.reviewState);
|
||||
if (explicit) return explicit;
|
||||
|
||||
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
||||
return task.kanbanColumn;
|
||||
return normalizeFallback(task.kanbanColumn) ?? 'none';
|
||||
}
|
||||
|
||||
return 'none';
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { getDerivedReviewState } from './taskHistory';
|
||||
import { getReviewStateFromTask } from './reviewState';
|
||||
|
||||
import type { TaskHistoryEvent, TeamReviewState } from '@shared/types';
|
||||
import type { TeamReviewState } from '@shared/types';
|
||||
|
||||
export type TaskChangeStateBucket = 'approved' | 'review' | 'completed' | 'active';
|
||||
|
||||
|
|
@ -11,25 +11,8 @@ interface TaskChangeStateLike {
|
|||
kanbanColumn?: 'review' | 'approved' | null;
|
||||
}
|
||||
|
||||
function normalizeReviewState(value: unknown): TeamReviewState {
|
||||
return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none';
|
||||
}
|
||||
|
||||
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
|
||||
if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) {
|
||||
return getDerivedReviewState({ historyEvents: task.historyEvents as TaskHistoryEvent[] });
|
||||
}
|
||||
|
||||
const explicit = normalizeReviewState(task.reviewState);
|
||||
if (explicit !== 'none') {
|
||||
return explicit;
|
||||
}
|
||||
|
||||
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
||||
return task.kanbanColumn;
|
||||
}
|
||||
|
||||
return 'none';
|
||||
return getReviewStateFromTask(task);
|
||||
}
|
||||
|
||||
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket {
|
||||
|
|
|
|||
|
|
@ -155,7 +155,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
|||
}),
|
||||
],
|
||||
});
|
||||
expect(cache.set).toHaveBeenCalledWith('recent-projects:fresh', result, 1_500);
|
||||
expect(cache.set).toHaveBeenCalledWith('recent-projects:fresh', result, 30_000);
|
||||
expect(logger.warn).toHaveBeenCalledWith('recent-projects source failed', {
|
||||
sourceId: 'source-1',
|
||||
sourceIndex: 1,
|
||||
|
|
@ -165,7 +165,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
|||
cacheKey: 'recent-projects:fresh',
|
||||
count: 1,
|
||||
degradedSources: 1,
|
||||
cacheTtlMs: 1_500,
|
||||
cacheTtlMs: 30_000,
|
||||
durationMs: 250,
|
||||
});
|
||||
});
|
||||
|
|
@ -242,7 +242,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
|||
expect(cache.set).toHaveBeenCalledWith(
|
||||
'recent-projects:timeout',
|
||||
{ ids: ['repo:fast'], sources: ['claude'] },
|
||||
1_500
|
||||
30_000
|
||||
);
|
||||
} finally {
|
||||
vi.useRealTimers();
|
||||
|
|
@ -311,13 +311,13 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
|||
expect(cache.set).toHaveBeenCalledWith(
|
||||
'recent-projects:stale',
|
||||
{ ids: ['repo:fresh'], sources: ['claude'] },
|
||||
1_500
|
||||
30_000
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
|
||||
cacheKey: 'recent-projects:stale',
|
||||
count: 1,
|
||||
degradedSources: 1,
|
||||
cacheTtlMs: 1_500,
|
||||
cacheTtlMs: 30_000,
|
||||
durationMs: 200,
|
||||
});
|
||||
});
|
||||
|
|
@ -364,11 +364,11 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
|||
|
||||
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale);
|
||||
expect(output.present).not.toHaveBeenCalled();
|
||||
expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 1_500);
|
||||
expect(cache.set).toHaveBeenCalledWith('recent-projects:stale', stale, 30_000);
|
||||
expect(logger.info).toHaveBeenCalledWith('recent-projects served stale cache', {
|
||||
cacheKey: 'recent-projects:stale',
|
||||
degradedSources: 1,
|
||||
cacheTtlMs: 1_500,
|
||||
cacheTtlMs: 30_000,
|
||||
durationMs: 200,
|
||||
});
|
||||
});
|
||||
|
|
@ -431,13 +431,13 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
|||
expect(cache.set).toHaveBeenCalledWith(
|
||||
'recent-projects:explicit-degraded',
|
||||
{ ids: ['repo:alpha'], sources: ['claude'] },
|
||||
1_500
|
||||
30_000
|
||||
);
|
||||
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
|
||||
cacheKey: 'recent-projects:explicit-degraded',
|
||||
count: 1,
|
||||
degradedSources: 1,
|
||||
cacheTtlMs: 1_500,
|
||||
cacheTtlMs: 30_000,
|
||||
durationMs: 0,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -166,7 +166,18 @@ describe('CodexRecentProjectsSourceAdapter', () => {
|
|||
candidates: [],
|
||||
degraded: true,
|
||||
});
|
||||
await expect(adapter.list()).resolves.toEqual({
|
||||
candidates: [],
|
||||
degraded: true,
|
||||
});
|
||||
expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1);
|
||||
expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled();
|
||||
expect(logger.info).toHaveBeenCalledWith(
|
||||
'codex recent-projects source cooldown active',
|
||||
expect.objectContaining({
|
||||
reason: 'codex app-server thread/list timed out after 8500ms',
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('drops Codex appstyle temp workspaces from dashboard candidates', async () => {
|
||||
|
|
@ -209,4 +220,81 @@ describe('CodexRecentProjectsSourceAdapter', () => {
|
|||
|
||||
expect(identityResolver.resolve).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('serves stale Codex candidates during a later full thread-list failure', async () => {
|
||||
const logger = createLogger();
|
||||
const appServerClient = {
|
||||
listRecentThreads: vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce({
|
||||
live: {
|
||||
threads: [
|
||||
{
|
||||
id: 'thread-live',
|
||||
cwd: '/Users/belief/dev/projects/headless',
|
||||
source: 'cli',
|
||||
updatedAt: 1_700_000_000,
|
||||
gitInfo: { branch: 'main' },
|
||||
},
|
||||
],
|
||||
},
|
||||
archived: {
|
||||
threads: [],
|
||||
},
|
||||
})
|
||||
.mockResolvedValueOnce({
|
||||
live: {
|
||||
threads: [],
|
||||
error: 'JSON-RPC request timed out: thread/list live',
|
||||
},
|
||||
archived: {
|
||||
threads: [],
|
||||
error: 'JSON-RPC request timed out: thread/list archived',
|
||||
},
|
||||
}),
|
||||
listRecentLiveThreads: vi.fn(),
|
||||
} as unknown as CodexAppServerClient;
|
||||
const identityResolver = {
|
||||
resolve: vi.fn().mockResolvedValue({
|
||||
id: 'repo:headless',
|
||||
name: 'headless',
|
||||
}),
|
||||
} as unknown as RecentProjectIdentityResolver;
|
||||
|
||||
const adapter = new CodexRecentProjectsSourceAdapter({
|
||||
getActiveContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
getLocalContext: () => ({ type: 'local', id: 'local-1' }) as never,
|
||||
resolveBinary: vi.fn().mockResolvedValue('/usr/local/bin/codex'),
|
||||
appServerClient,
|
||||
identityResolver,
|
||||
logger,
|
||||
});
|
||||
|
||||
await expect(adapter.list()).resolves.toEqual({
|
||||
candidates: [
|
||||
expect.objectContaining({
|
||||
identity: 'repo:headless',
|
||||
primaryPath: '/Users/belief/dev/projects/headless',
|
||||
}),
|
||||
],
|
||||
degraded: false,
|
||||
});
|
||||
|
||||
await expect(adapter.list()).resolves.toEqual({
|
||||
candidates: [
|
||||
expect.objectContaining({
|
||||
identity: 'repo:headless',
|
||||
primaryPath: '/Users/belief/dev/projects/headless',
|
||||
}),
|
||||
],
|
||||
degraded: true,
|
||||
});
|
||||
|
||||
expect(identityResolver.resolve).toHaveBeenCalledTimes(1);
|
||||
expect(logger.info).toHaveBeenCalledWith('codex recent-projects served stale candidates', {
|
||||
count: 1,
|
||||
reason:
|
||||
'live: JSON-RPC request timed out: thread/list live; archived: JSON-RPC request timed out: thread/list archived',
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -21,8 +21,9 @@ function createSession(
|
|||
|
||||
describe('CodexAppServerClient', () => {
|
||||
it('loads live and archived threads in a single app-server session', async () => {
|
||||
const session = createSession(
|
||||
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockImplementation((method: string, params?: { archived?: boolean }) => {
|
||||
if (method === 'initialize') {
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
|
@ -40,8 +41,8 @@ describe('CodexAppServerClient', () => {
|
|||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected method: ${method}`));
|
||||
})
|
||||
);
|
||||
});
|
||||
const session = createSession(request);
|
||||
|
||||
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
|
||||
const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient);
|
||||
|
|
@ -58,11 +59,23 @@ describe('CodexAppServerClient', () => {
|
|||
expect.objectContaining({
|
||||
binaryPath: '/usr/local/bin/codex',
|
||||
requestTimeoutMs: 4500,
|
||||
totalTimeoutMs: 12000,
|
||||
totalTimeoutMs: 14500,
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
expect(session.notify).toHaveBeenCalledWith('initialized');
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
2,
|
||||
'thread/list',
|
||||
expect.objectContaining({ archived: false }),
|
||||
4500
|
||||
);
|
||||
expect(request).toHaveBeenNthCalledWith(
|
||||
3,
|
||||
'thread/list',
|
||||
expect.objectContaining({ archived: true }),
|
||||
2500
|
||||
);
|
||||
expect(result).toEqual({
|
||||
live: {
|
||||
threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
|
||||
|
|
@ -113,7 +126,7 @@ describe('CodexAppServerClient', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('raises the session timeout budget above the longest request timeout', async () => {
|
||||
it('raises the session timeout budget above sequential request timeouts', async () => {
|
||||
const session = createSession(
|
||||
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
|
||||
if (method === 'initialize') {
|
||||
|
|
@ -140,7 +153,7 @@ describe('CodexAppServerClient', () => {
|
|||
|
||||
expect(withSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
totalTimeoutMs: 12000,
|
||||
totalTimeoutMs: 14500,
|
||||
}),
|
||||
expect.any(Function)
|
||||
);
|
||||
|
|
@ -187,18 +200,20 @@ describe('CodexAppServerClient', () => {
|
|||
});
|
||||
|
||||
it('uses the longer initialize timeout for app-server startup', async () => {
|
||||
const request = vi.fn().mockImplementation((method: string, _params?: unknown, timeoutMs?: number) => {
|
||||
if (method === 'initialize') {
|
||||
expect(timeoutMs).toBe(6000);
|
||||
return Promise.resolve({});
|
||||
}
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockImplementation((method: string, _params?: unknown, timeoutMs?: number) => {
|
||||
if (method === 'initialize') {
|
||||
expect(timeoutMs).toBe(6000);
|
||||
return Promise.resolve({});
|
||||
}
|
||||
|
||||
if (method === 'thread/list') {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
if (method === 'thread/list') {
|
||||
return Promise.resolve({ data: [] });
|
||||
}
|
||||
|
||||
return Promise.reject(new Error(`Unexpected method: ${method}`));
|
||||
});
|
||||
return Promise.reject(new Error(`Unexpected method: ${method}`));
|
||||
});
|
||||
|
||||
const session = createSession(request);
|
||||
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
|
||||
|
|
|
|||
|
|
@ -103,7 +103,7 @@ describe('recentProjectsClientCache', () => {
|
|||
await expect(second).resolves.toEqual(payload('alpha'));
|
||||
});
|
||||
|
||||
it('marks degraded payload snapshots stale faster than healthy payloads', async () => {
|
||||
it('keeps degraded payload snapshots fresh long enough to avoid hot retry loops', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z'));
|
||||
|
||||
|
|
@ -121,7 +121,13 @@ describe('recentProjectsClientCache', () => {
|
|||
isStale: false,
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-04-14T12:00:02.000Z'));
|
||||
vi.setSystemTime(new Date('2026-04-14T12:00:20.000Z'));
|
||||
expect(getRecentProjectsClientSnapshot()).toMatchObject({
|
||||
payload: payload('alpha', { degraded: true }),
|
||||
isStale: false,
|
||||
});
|
||||
|
||||
vi.setSystemTime(new Date('2026-04-14T12:00:31.000Z'));
|
||||
expect(getRecentProjectsClientSnapshot()).toMatchObject({
|
||||
payload: payload('alpha', { degraded: true }),
|
||||
isStale: true,
|
||||
|
|
@ -129,7 +135,9 @@ describe('recentProjectsClientCache', () => {
|
|||
});
|
||||
|
||||
it('normalizes legacy array responses from the loader during mixed-version dev reloads', async () => {
|
||||
const loader = vi.fn<() => Promise<DashboardRecentProject[]>>().mockResolvedValue([project('alpha')]);
|
||||
const loader = vi
|
||||
.fn<() => Promise<DashboardRecentProject[]>>()
|
||||
.mockResolvedValue([project('alpha')]);
|
||||
|
||||
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
|
||||
expect(getRecentProjectsClientSnapshot()?.payload).toEqual(payload('alpha'));
|
||||
|
|
|
|||
|
|
@ -0,0 +1,20 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { parseRuntimeProcessTable } from '@features/tmux-installer/main';
|
||||
|
||||
describe('parseRuntimeProcessTable', () => {
|
||||
it('parses pid, ppid and command rows', () => {
|
||||
expect(
|
||||
parseRuntimeProcessTable(' 10 1 /bin/zsh\n 11 10 node runtime --team-name demo')
|
||||
).toEqual([
|
||||
{ pid: 10, ppid: 1, command: '/bin/zsh' },
|
||||
{ pid: 11, ppid: 10, command: 'node runtime --team-name demo' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('skips malformed rows', () => {
|
||||
expect(parseRuntimeProcessTable('bad\n 0 1 nope\n 12 0 /bin/node')).toEqual([
|
||||
{ pid: 12, ppid: 0, command: '/bin/node' },
|
||||
]);
|
||||
});
|
||||
});
|
||||
198
test/main/services/team/AnthropicRuntimeMemory.live.test.ts
Normal file
198
test/main/services/team/AnthropicRuntimeMemory.live.test.ts
Normal file
|
|
@ -0,0 +1,198 @@
|
|||
import { constants as fsConstants, promises as fs } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
import type {
|
||||
TeamAgentRuntimeSnapshot,
|
||||
TeamProvisioningProgress,
|
||||
} from '../../../../src/shared/types';
|
||||
|
||||
const liveDescribe =
|
||||
process.env.ANTHROPIC_RUNTIME_MEMORY_LIVE === '1' && process.env.ANTHROPIC_API_KEY?.trim()
|
||||
? describe
|
||||
: describe.skip;
|
||||
|
||||
const DEFAULT_ORCHESTRATOR_CLI = '/Users/belief/dev/projects/claude/agent_teams_orchestrator/cli';
|
||||
const DEFAULT_MODEL = 'haiku';
|
||||
|
||||
liveDescribe('Anthropic runtime memory live e2e', () => {
|
||||
let tempDir: string;
|
||||
let tempClaudeRoot: string;
|
||||
let previousCliPath: string | undefined;
|
||||
let previousCliFlavor: string | undefined;
|
||||
let previousDisableAppBootstrap: string | undefined;
|
||||
let previousDisableRuntimeBootstrap: string | undefined;
|
||||
let previousHome: string | undefined;
|
||||
let previousUserProfile: string | undefined;
|
||||
let svc: TeamProvisioningService | null;
|
||||
let teamName: string | null;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'anthropic-runtime-memory-live-'));
|
||||
tempClaudeRoot = path.join(tempDir, '.claude');
|
||||
const tempHome = path.join(tempDir, 'home');
|
||||
await fs.mkdir(tempClaudeRoot, { recursive: true });
|
||||
await fs.mkdir(tempHome, { recursive: true });
|
||||
setClaudeBasePathOverride(tempClaudeRoot);
|
||||
previousCliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH;
|
||||
previousCliFlavor = process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||
previousDisableAppBootstrap = process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
previousDisableRuntimeBootstrap = process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
previousHome = process.env.HOME;
|
||||
previousUserProfile = process.env.USERPROFILE;
|
||||
process.env.HOME = tempHome;
|
||||
process.env.USERPROFILE = tempHome;
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH =
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() || DEFAULT_ORCHESTRATOR_CLI;
|
||||
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'agent_teams_orchestrator';
|
||||
delete process.env.CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
delete process.env.CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP;
|
||||
svc = null;
|
||||
teamName = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (svc && teamName) {
|
||||
await svc.stopTeam(teamName).catch(() => undefined);
|
||||
}
|
||||
setClaudeBasePathOverride(null);
|
||||
restoreEnv('CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH', previousCliPath);
|
||||
restoreEnv('CLAUDE_TEAM_CLI_FLAVOR', previousCliFlavor);
|
||||
restoreEnv('CLAUDE_APP_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableAppBootstrap);
|
||||
restoreEnv('CLAUDE_DISABLE_DETERMINISTIC_TEAM_BOOTSTRAP', previousDisableRuntimeBootstrap);
|
||||
restoreEnv('HOME', previousHome);
|
||||
restoreEnv('USERPROFILE', previousUserProfile);
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('creates a real Anthropic team and reports teammate RSS in the runtime snapshot', async () => {
|
||||
const orchestratorCli = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim();
|
||||
expect(orchestratorCli).toBeTruthy();
|
||||
await assertExecutable(orchestratorCli!);
|
||||
|
||||
const selectedModel = process.env.ANTHROPIC_RUNTIME_MEMORY_LIVE_MODEL?.trim() || DEFAULT_MODEL;
|
||||
teamName = `anthropic-memory-live-${Date.now()}`;
|
||||
const projectPath = path.join(tempDir, 'project');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(projectPath, 'README.md'),
|
||||
'# Anthropic runtime memory live e2e\n',
|
||||
'utf8'
|
||||
);
|
||||
|
||||
svc = new TeamProvisioningService();
|
||||
const progressEvents: TeamProvisioningProgress[] = [];
|
||||
|
||||
await svc.createTeam(
|
||||
{
|
||||
teamName,
|
||||
cwd: projectPath,
|
||||
providerId: 'anthropic',
|
||||
model: selectedModel,
|
||||
skipPermissions: true,
|
||||
prompt: 'Keep the team idle after bootstrap. Do not start extra work.',
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
role: 'Developer',
|
||||
providerId: 'anthropic',
|
||||
model: selectedModel,
|
||||
},
|
||||
],
|
||||
},
|
||||
(progress) => {
|
||||
progressEvents.push(progress);
|
||||
}
|
||||
);
|
||||
|
||||
await waitUntil(async () => {
|
||||
const last = progressEvents.at(-1);
|
||||
if (last?.state === 'failed') {
|
||||
throw new Error(formatProgressDump(progressEvents));
|
||||
}
|
||||
if (last?.state === 'ready') {
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}, 240_000);
|
||||
|
||||
let snapshot: TeamAgentRuntimeSnapshot | null = null;
|
||||
await waitUntil(async () => {
|
||||
snapshot = await svc!.getTeamAgentRuntimeSnapshot(teamName!);
|
||||
const alice = snapshot.members.alice;
|
||||
return (
|
||||
alice?.providerId === 'anthropic' &&
|
||||
alice.pidSource === 'agent_process_table' &&
|
||||
alice.livenessKind === 'runtime_process' &&
|
||||
typeof alice.pid === 'number' &&
|
||||
typeof alice.rssBytes === 'number' &&
|
||||
alice.rssBytes > 0
|
||||
);
|
||||
}, 60_000);
|
||||
|
||||
expect(snapshot!.members.alice).toMatchObject({
|
||||
alive: true,
|
||||
providerId: 'anthropic',
|
||||
pidSource: 'agent_process_table',
|
||||
livenessKind: 'runtime_process',
|
||||
runtimeModel: selectedModel,
|
||||
});
|
||||
expect(snapshot!.members.alice.rssBytes).toBeGreaterThan(0);
|
||||
}, 300_000);
|
||||
});
|
||||
|
||||
function restoreEnv(name: string, previous: string | undefined): void {
|
||||
if (previous === undefined) {
|
||||
delete process.env[name];
|
||||
} else {
|
||||
process.env[name] = previous;
|
||||
}
|
||||
}
|
||||
|
||||
async function assertExecutable(filePath: string): Promise<void> {
|
||||
await fs.access(filePath, fsConstants.X_OK);
|
||||
}
|
||||
|
||||
async function waitUntil(
|
||||
predicate: () => Promise<boolean>,
|
||||
timeoutMs: number,
|
||||
pollMs = 1_000
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + timeoutMs;
|
||||
let lastError: unknown;
|
||||
while (Date.now() < deadline) {
|
||||
try {
|
||||
if (await predicate()) {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
lastError = error;
|
||||
throw error;
|
||||
}
|
||||
await new Promise((resolve) => setTimeout(resolve, pollMs));
|
||||
}
|
||||
const suffix =
|
||||
lastError instanceof Error && lastError.message ? ` Last error: ${lastError.message}` : '';
|
||||
throw new Error(`Timed out after ${timeoutMs}ms waiting for condition.${suffix}`);
|
||||
}
|
||||
|
||||
function formatProgressDump(progressEvents: TeamProvisioningProgress[]): string {
|
||||
return progressEvents
|
||||
.map((progress) =>
|
||||
[
|
||||
progress.state,
|
||||
progress.message,
|
||||
progress.messageSeverity,
|
||||
progress.error,
|
||||
progress.cliLogsTail,
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join(' | ')
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
|
@ -311,7 +311,7 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
alice: {
|
||||
providerId: 'opencode',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
},
|
||||
},
|
||||
|
|
@ -501,8 +501,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
providerId: 'opencode',
|
||||
launchState: 'runtime_pending_permission',
|
||||
pendingPermissionRequestIds: ['perm-1', 'perm-2'],
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
agentToolAccepted: true,
|
||||
livenessKind: 'permission_blocked',
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
},
|
||||
|
|
@ -517,6 +518,116 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not mark created bridge members without runtimePid as runtimeAlive', async () => {
|
||||
const launchOpenCodeTeam = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'launching',
|
||||
members: {
|
||||
alice: {
|
||||
sessionId: 'oc-session-1',
|
||||
launchState: 'created',
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
evidence: [],
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
}) satisfies OpenCodeLaunchTeamCommandData
|
||||
);
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
launchOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
);
|
||||
|
||||
const result = await adapter.launch(launchInput());
|
||||
|
||||
expect(result.members.alice).toMatchObject({
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
runtimeDiagnostic: 'OpenCode session exists without verified runtime pid',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps created bridge runtimePid provisional until local process verification', async () => {
|
||||
const launchOpenCodeTeam = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'launching',
|
||||
members: {
|
||||
alice: {
|
||||
sessionId: 'oc-session-1',
|
||||
launchState: 'created',
|
||||
runtimePid: 123,
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
evidence: [],
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
}) satisfies OpenCodeLaunchTeamCommandData
|
||||
);
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
launchOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
);
|
||||
|
||||
const result = await adapter.launch(launchInput());
|
||||
|
||||
expect(result.members.alice).toMatchObject({
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
runtimePid: 123,
|
||||
runtimeDiagnostic: 'OpenCode runtime pid reported by bridge without local process verification',
|
||||
});
|
||||
});
|
||||
|
||||
it('treats materialized bridge members without session or pid as accepted but not alive', async () => {
|
||||
const launchOpenCodeTeam = vi.fn(
|
||||
async () =>
|
||||
({
|
||||
runId: 'run-1',
|
||||
teamLaunchState: 'launching',
|
||||
members: {
|
||||
alice: {
|
||||
sessionId: '',
|
||||
launchState: 'created',
|
||||
model: 'openai/gpt-5.4-mini',
|
||||
evidence: [],
|
||||
},
|
||||
},
|
||||
warnings: [],
|
||||
diagnostics: [],
|
||||
}) satisfies OpenCodeLaunchTeamCommandData
|
||||
);
|
||||
const adapter = new OpenCodeTeamRuntimeAdapter(
|
||||
bridgePort(readiness({ state: 'ready', launchAllowed: true }), {
|
||||
launchOpenCodeTeam,
|
||||
}),
|
||||
{ launchMode: 'dogfood' }
|
||||
);
|
||||
|
||||
const result = await adapter.launch(launchInput());
|
||||
|
||||
expect(result.members.alice).toMatchObject({
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
runtimeDiagnostic: 'OpenCode session exists without verified runtime pid',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps missing bridge members in bootstrap pending even when another member blocks on permission', async () => {
|
||||
const launchOpenCodeTeam = vi.fn(
|
||||
async () =>
|
||||
|
|
|
|||
|
|
@ -211,9 +211,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
const statuses = await svc.getMemberSpawnStatuses('permission-opencode-safe-e2e');
|
||||
expect(statuses.teamLaunchState).toBe('partial_pending');
|
||||
expect(statuses.statuses.alice).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
pendingPermissionRequestIds: ['perm-alice'],
|
||||
});
|
||||
expect(statuses.summary?.pendingCount).toBe(1);
|
||||
|
|
@ -255,9 +255,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
bootstrapConfirmed: true,
|
||||
});
|
||||
expect(statuses.statuses.bob).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
pendingPermissionRequestIds: ['perm-bob'],
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
|
|
@ -2300,7 +2300,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
confirmedCount: 2,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(statuses.statuses.alice).toMatchObject({
|
||||
status: 'online',
|
||||
|
|
@ -2313,9 +2313,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
});
|
||||
|
|
@ -2909,7 +2909,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
confirmedCount: 1,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(statuses.statuses.alice).toMatchObject({
|
||||
status: 'online',
|
||||
|
|
@ -2917,9 +2917,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.bob).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
});
|
||||
|
|
@ -2972,12 +2972,12 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
confirmedCount: 1,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(statuses.statuses.bob).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
pendingPermissionRequestIds: ['perm-bob'],
|
||||
hardFailure: false,
|
||||
});
|
||||
|
|
@ -3301,13 +3301,13 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
confirmedCount: 1,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(statuses.statuses.bob).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
});
|
||||
|
|
@ -3361,13 +3361,13 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
confirmedCount: 1,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(statuses.statuses.bob).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
pendingPermissionRequestIds: ['perm-bob'],
|
||||
hardFailure: false,
|
||||
});
|
||||
|
|
@ -3469,7 +3469,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
hardFailure: false,
|
||||
pendingPermissionRequestIds: ['perm-tom'],
|
||||
|
|
@ -3588,7 +3588,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
hardFailure: false,
|
||||
pendingPermissionRequestIds: ['perm-tom'],
|
||||
|
|
@ -4129,6 +4129,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
{
|
||||
alive: true,
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
livenessKind: 'runtime_process',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
|
@ -4196,6 +4197,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
{
|
||||
alive: true,
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
livenessKind: 'runtime_process',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
|
@ -4364,9 +4366,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
bootstrapConfirmed: true,
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
pendingPermissionRequestIds: ['perm-tom'],
|
||||
});
|
||||
});
|
||||
|
|
@ -4537,12 +4539,12 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
confirmedCount: 2,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(statuses.statuses.alice).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
pendingPermissionRequestIds: ['perm-alice'],
|
||||
hardFailure: false,
|
||||
|
|
@ -4591,7 +4593,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
confirmedCount: 2,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(statuses.statuses.alice).toMatchObject({
|
||||
status: 'online',
|
||||
|
|
@ -4606,9 +4608,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
});
|
||||
|
|
@ -4629,7 +4631,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
expect(runtimeSnapshot.members.tom).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
laneKind: 'secondary',
|
||||
alive: true,
|
||||
alive: false,
|
||||
runtimeModel: 'opencode/nemotron-3-super-free',
|
||||
});
|
||||
});
|
||||
|
|
@ -4694,7 +4696,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
confirmedCount: 2,
|
||||
pendingCount: 1,
|
||||
failedCount: 1,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(statuses.statuses.alice).toMatchObject({
|
||||
status: 'online',
|
||||
|
|
@ -4715,9 +4717,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
});
|
||||
|
|
@ -4744,7 +4746,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
expect(runtimeSnapshot.members.tom).toMatchObject({
|
||||
providerId: 'opencode',
|
||||
laneKind: 'secondary',
|
||||
alive: true,
|
||||
alive: false,
|
||||
runtimeModel: 'opencode/nemotron-3-super-free',
|
||||
});
|
||||
});
|
||||
|
|
@ -4934,7 +4936,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
hardFailure: false,
|
||||
pendingPermissionRequestIds: ['perm-tom'],
|
||||
|
|
@ -5392,8 +5394,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
['alice', { alive: true, model: 'haiku' }],
|
||||
['bob-2', { alive: true, model: 'sonnet' }],
|
||||
['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }],
|
||||
['bob-2', { alive: true, model: 'sonnet', livenessKind: 'runtime_process' }],
|
||||
]);
|
||||
|
||||
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
||||
|
|
@ -5458,8 +5460,15 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
['alice', { alive: true, model: 'haiku' }],
|
||||
['bob-2', { alive: true, model: 'opencode/minimax-m2.5-free' }],
|
||||
['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }],
|
||||
[
|
||||
'bob-2',
|
||||
{
|
||||
alive: true,
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
livenessKind: 'runtime_process',
|
||||
},
|
||||
],
|
||||
]);
|
||||
|
||||
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
||||
|
|
@ -6832,8 +6841,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([
|
||||
['alice', { alive: true, model: 'haiku' }],
|
||||
['bob-2', { alive: true, model: 'sonnet' }],
|
||||
['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }],
|
||||
['bob-2', { alive: true, model: 'sonnet', livenessKind: 'runtime_process' }],
|
||||
]);
|
||||
|
||||
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
||||
|
|
@ -6966,7 +6975,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
});
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||
new Map([['alice', { alive: true, model: 'haiku' }]]);
|
||||
new Map([['alice', { alive: true, model: 'haiku', livenessKind: 'runtime_process' }]]);
|
||||
|
||||
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
||||
|
||||
|
|
@ -13102,7 +13111,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailureReason: 'Gemini pane failed to start',
|
||||
});
|
||||
expect(statuses.statuses.bob).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
pendingPermissionRequestIds: ['perm-bob'],
|
||||
hardFailure: false,
|
||||
|
|
@ -13238,7 +13247,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
launchState: 'confirmed_alive',
|
||||
});
|
||||
expect(statuses.statuses.bob).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
pendingPermissionRequestIds: ['perm-bob'],
|
||||
hardFailure: false,
|
||||
|
|
@ -13291,7 +13300,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.bob).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
pendingPermissionRequestIds: ['perm-bob'],
|
||||
hardFailure: false,
|
||||
|
|
@ -13375,7 +13384,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
});
|
||||
expect(secondStatuses.teamLaunchState).toBe('partial_pending');
|
||||
expect(secondStatuses.statuses.bob).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
pendingPermissionRequestIds: ['perm-bob'],
|
||||
hardFailure: false,
|
||||
|
|
@ -15570,7 +15579,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
hardFailure: false,
|
||||
pendingPermissionRequestIds: ['perm-tom'],
|
||||
|
|
@ -15622,7 +15631,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
confirmedCount: 1,
|
||||
pendingCount: 2,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(statuses.statuses.bob).toMatchObject({
|
||||
status: 'online',
|
||||
|
|
@ -15630,9 +15639,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
});
|
||||
|
|
@ -15703,7 +15712,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_permission',
|
||||
hardFailure: false,
|
||||
pendingPermissionRequestIds: ['perm-tom'],
|
||||
|
|
@ -15763,7 +15772,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
confirmedCount: 1,
|
||||
pendingCount: 3,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(statuses.expectedMembers).toEqual(
|
||||
expect.arrayContaining(['alice', 'reviewer', 'bob', 'tom'])
|
||||
|
|
@ -15776,9 +15785,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
|||
hardFailure: false,
|
||||
});
|
||||
expect(statuses.statuses.tom).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
});
|
||||
|
|
@ -16058,6 +16067,18 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
const failed = outcome === 'failed';
|
||||
const permissionPending = outcome === 'permission';
|
||||
const bootstrapPending = outcome === 'launching';
|
||||
const livenessKind = failed
|
||||
? 'not_found'
|
||||
: permissionPending
|
||||
? 'permission_blocked'
|
||||
: bootstrapPending
|
||||
? 'runtime_process_candidate'
|
||||
: 'confirmed_bootstrap';
|
||||
const runtimeDiagnostic = permissionPending
|
||||
? 'OpenCode runtime is waiting for permission approval'
|
||||
: bootstrapPending
|
||||
? 'OpenCode runtime pid reported by bridge without local process verification'
|
||||
: undefined;
|
||||
return {
|
||||
memberName: member.name,
|
||||
providerId: 'opencode',
|
||||
|
|
@ -16069,12 +16090,15 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
|||
? 'runtime_pending_bootstrap'
|
||||
: 'confirmed_alive',
|
||||
agentToolAccepted: !failed,
|
||||
runtimeAlive: !failed,
|
||||
runtimeAlive: !failed && !permissionPending && !bootstrapPending,
|
||||
bootstrapConfirmed: !failed && !permissionPending && !bootstrapPending,
|
||||
hardFailure: failed,
|
||||
hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined,
|
||||
pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined,
|
||||
runtimePid: failed ? undefined : 10_000 + index,
|
||||
livenessKind,
|
||||
pidSource: failed ? undefined : 'opencode_bridge',
|
||||
runtimeDiagnostic,
|
||||
diagnostics: failed
|
||||
? ['fake OpenCode launch failure']
|
||||
: permissionPending
|
||||
|
|
|
|||
|
|
@ -279,6 +279,39 @@ describe('TeamBootstrapStateReader', () => {
|
|||
await expect(readBootstrapRuntimeState('demo')).resolves.toBeNull();
|
||||
});
|
||||
|
||||
it('does not promote bootstrap-state runtime_alive to strict runtimeAlive', async () => {
|
||||
hoisted.files.set('/mock/teams/demo/bootstrap-state.json', {
|
||||
contents: JSON.stringify({
|
||||
version: 1,
|
||||
runId: 'run-123',
|
||||
teamName: 'demo',
|
||||
startedAt: 1700000000000,
|
||||
updatedAt: 1700000000500,
|
||||
phase: 'spawning_members',
|
||||
members: [{ name: 'alice', status: 'runtime_alive', lastObservedAt: 1700000000400 }],
|
||||
}),
|
||||
});
|
||||
|
||||
await expect(readBootstrapLaunchSnapshot('demo')).resolves.toMatchObject({
|
||||
launchPhase: 'active',
|
||||
members: {
|
||||
alice: {
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
sources: {
|
||||
configRegistered: true,
|
||||
},
|
||||
diagnostics: [
|
||||
'runtime alive reported by bootstrap state',
|
||||
'waiting for strict live verification',
|
||||
],
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it('reads persisted real-task submission state', async () => {
|
||||
hoisted.files.set('/mock/teams/demo/bootstrap-state.json', {
|
||||
contents: JSON.stringify({
|
||||
|
|
|
|||
|
|
@ -242,10 +242,12 @@ function createForwardingJournalStore(initialEntries: Array<Record<string, unkno
|
|||
const journal = {
|
||||
exists: vi.fn(async () => true),
|
||||
ensureFile: vi.fn(async () => undefined),
|
||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}),
|
||||
withEntries: vi.fn(
|
||||
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
return { journalEntries, journal };
|
||||
|
|
@ -262,7 +264,9 @@ function createTaskCommentForwardingService(options: {
|
|||
};
|
||||
members?: Array<{ name: string; role?: string }>;
|
||||
}) {
|
||||
const inboxWriter = options.inboxWriter ?? { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })) };
|
||||
const inboxWriter = options.inboxWriter ?? {
|
||||
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })),
|
||||
};
|
||||
const journal = options.journal ?? createForwardingJournalStore().journal;
|
||||
|
||||
const service = new TeamDataService(
|
||||
|
|
@ -328,24 +332,26 @@ function buildDefaultTeamConfig(overrides: Partial<TeamConfig> = {}): TeamConfig
|
|||
};
|
||||
}
|
||||
|
||||
function createGetTeamDataHarness(options: {
|
||||
config?: TeamConfig | null;
|
||||
getTasks?: () => Promise<TeamTask[]>;
|
||||
listInboxNames?: () => Promise<string[]>;
|
||||
getMessages?: () => Promise<InboxMessage[]>;
|
||||
getMembers?: () => Promise<TeamConfig['members']>;
|
||||
getTeamMeta?: () => Promise<TeamMetaFile | null>;
|
||||
getState?: () => Promise<KanbanState>;
|
||||
readMessages?: () => Promise<InboxMessage[]>;
|
||||
resolveMembers?: (
|
||||
config: TeamConfig,
|
||||
metaMembers: TeamConfig['members'],
|
||||
inboxNames: string[],
|
||||
tasks: TeamTaskWithKanban[]
|
||||
) => ResolvedTeamMember[];
|
||||
listProcesses?: () => TeamProcess[];
|
||||
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
|
||||
} = {}) {
|
||||
function createGetTeamDataHarness(
|
||||
options: {
|
||||
config?: TeamConfig | null;
|
||||
getTasks?: () => Promise<TeamTask[]>;
|
||||
listInboxNames?: () => Promise<string[]>;
|
||||
getMessages?: () => Promise<InboxMessage[]>;
|
||||
getMembers?: () => Promise<TeamConfig['members']>;
|
||||
getTeamMeta?: () => Promise<TeamMetaFile | null>;
|
||||
getState?: () => Promise<KanbanState>;
|
||||
readMessages?: () => Promise<InboxMessage[]>;
|
||||
resolveMembers?: (
|
||||
config: TeamConfig,
|
||||
metaMembers: TeamConfig['members'],
|
||||
inboxNames: string[],
|
||||
tasks: TeamTaskWithKanban[]
|
||||
) => ResolvedTeamMember[];
|
||||
listProcesses?: () => TeamProcess[];
|
||||
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
|
||||
} = {}
|
||||
) {
|
||||
const getConfig = vi.fn(async () =>
|
||||
options.config === undefined ? buildDefaultTeamConfig() : options.config
|
||||
);
|
||||
|
|
@ -486,7 +492,9 @@ describe('TeamDataService', () => {
|
|||
{} as never,
|
||||
{} as never,
|
||||
{ resolveMembers: vi.fn(() => []) } as never,
|
||||
{ getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })),
|
||||
} as never,
|
||||
{} as never,
|
||||
membersMetaStore,
|
||||
{ readMessages: vi.fn(async () => []) } as never
|
||||
|
|
@ -518,7 +526,9 @@ describe('TeamDataService', () => {
|
|||
{} as never,
|
||||
{} as never,
|
||||
{ resolveMembers: vi.fn(() => []) } as never,
|
||||
{ getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })) } as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'dup-team', reviewers: [], tasks: {} })),
|
||||
} as never,
|
||||
{} as never,
|
||||
membersMetaStore,
|
||||
{ readMessages: vi.fn(async () => []) } as never
|
||||
|
|
@ -1006,7 +1016,10 @@ describe('TeamDataService', () => {
|
|||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })),
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'My team',
|
||||
members: [{ name: 'team-lead', role: 'Lead' }],
|
||||
})),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
|
|
@ -1134,7 +1147,10 @@ describe('TeamDataService', () => {
|
|||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({ name: 'My team', members: [{ name: 'team-lead', role: 'Lead' }] })),
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'My team',
|
||||
members: [{ name: 'team-lead', role: 'Lead' }],
|
||||
})),
|
||||
} as never,
|
||||
{
|
||||
getTasks: vi.fn(async () => []),
|
||||
|
|
@ -1305,7 +1321,9 @@ describe('TeamDataService', () => {
|
|||
expect(createTaskMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ owner: 'alice', createdBy: 'user' })
|
||||
);
|
||||
expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ startImmediately: true }));
|
||||
expect(createTaskMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ startImmediately: true })
|
||||
);
|
||||
});
|
||||
|
||||
it('creates task with explicit immediate start only when startImmediately is true', async () => {
|
||||
|
|
@ -1362,7 +1380,9 @@ describe('TeamDataService', () => {
|
|||
prompt: 'Begin immediately.',
|
||||
})
|
||||
);
|
||||
expect(createTaskMock).not.toHaveBeenCalledWith(expect.objectContaining({ status: 'in_progress' }));
|
||||
expect(createTaskMock).not.toHaveBeenCalledWith(
|
||||
expect.objectContaining({ status: 'in_progress' })
|
||||
);
|
||||
});
|
||||
|
||||
it('persists explicit related task links when creating a task', async () => {
|
||||
|
|
@ -1486,7 +1506,47 @@ describe('TeamDataService', () => {
|
|||
await service.requestReview('my-team', 'task-1');
|
||||
|
||||
expect(requestReviewMock).toHaveBeenCalledWith('task-1', {
|
||||
from: 'user',
|
||||
from: 'lead',
|
||||
leadSessionId: 'lead-1',
|
||||
});
|
||||
});
|
||||
|
||||
it('resolves the canonical lead instead of matching tech-lead role text', async () => {
|
||||
const requestReviewMock = vi.fn();
|
||||
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'My team',
|
||||
members: [
|
||||
{ name: 'alice', role: 'tech lead' },
|
||||
{ name: 'team-lead', agentType: 'team-lead', role: 'lead' },
|
||||
],
|
||||
leadSessionId: 'lead-1',
|
||||
})),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
() =>
|
||||
({
|
||||
review: {
|
||||
requestReview: requestReviewMock,
|
||||
},
|
||||
}) as never
|
||||
);
|
||||
|
||||
await service.requestReview('my-team', 'task-1');
|
||||
|
||||
expect(requestReviewMock).toHaveBeenCalledWith('task-1', {
|
||||
from: 'team-lead',
|
||||
leadSessionId: 'lead-1',
|
||||
});
|
||||
});
|
||||
|
|
@ -1511,8 +1571,15 @@ describe('TeamDataService', () => {
|
|||
subject: 'Legacy review task',
|
||||
status: 'completed',
|
||||
owner: 'bob',
|
||||
reviewState: 'review',
|
||||
historyEvents: [],
|
||||
reviewState: 'none',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-created',
|
||||
type: 'task_created',
|
||||
status: 'completed',
|
||||
timestamp: '2026-03-01T09:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
} as never,
|
||||
|
|
@ -1550,6 +1617,129 @@ describe('TeamDataService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('does not leak stale reviewer after review is reset to pending', async () => {
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
getConfig: vi.fn(async () => ({
|
||||
name: 'My team',
|
||||
members: [
|
||||
{ name: 'lead', role: 'team lead' },
|
||||
{ name: 'bob', role: 'developer' },
|
||||
{ name: 'carol', role: 'reviewer' },
|
||||
],
|
||||
})),
|
||||
} as never,
|
||||
{
|
||||
getTasks: vi.fn(async () => [
|
||||
{
|
||||
id: 'task-reopened',
|
||||
subject: 'Reopened task',
|
||||
status: 'pending',
|
||||
owner: 'bob',
|
||||
reviewState: 'none',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-review',
|
||||
type: 'review_requested',
|
||||
from: 'none',
|
||||
to: 'review',
|
||||
reviewer: 'carol',
|
||||
timestamp: '2026-03-01T10:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'evt-pending',
|
||||
type: 'status_changed',
|
||||
from: 'completed',
|
||||
to: 'pending',
|
||||
timestamp: '2026-03-01T10:05:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
} as never,
|
||||
{
|
||||
listInboxNames: vi.fn(async () => []),
|
||||
getMessages: vi.fn(async () => []),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
resolveMembers: vi.fn(() => []),
|
||||
} as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||
} as never
|
||||
);
|
||||
|
||||
const data = await service.getTeamData('my-team');
|
||||
|
||||
expect(data.tasks[0]).toMatchObject({
|
||||
id: 'task-reopened',
|
||||
reviewState: 'none',
|
||||
reviewer: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('applies kanban overlay review state in global task projections', async () => {
|
||||
const service = new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(async () => [
|
||||
{
|
||||
teamName: 'my-team',
|
||||
displayName: 'My team',
|
||||
projectPath: '/repo',
|
||||
},
|
||||
]),
|
||||
} as never,
|
||||
{
|
||||
getAllTasks: vi.fn(async () => [
|
||||
{
|
||||
id: 'task-global-review',
|
||||
teamName: 'my-team',
|
||||
subject: 'Global review task',
|
||||
status: 'completed',
|
||||
owner: 'bob',
|
||||
reviewState: 'none',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-created',
|
||||
type: 'task_created',
|
||||
status: 'completed',
|
||||
timestamp: '2026-03-01T09:00:00.000Z',
|
||||
},
|
||||
],
|
||||
},
|
||||
]),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({
|
||||
teamName: 'my-team',
|
||||
reviewers: [],
|
||||
tasks: {
|
||||
'task-global-review': {
|
||||
column: 'review',
|
||||
reviewer: 'carol',
|
||||
movedAt: '2026-03-01T10:00:00.000Z',
|
||||
},
|
||||
},
|
||||
})),
|
||||
} as never
|
||||
);
|
||||
|
||||
const tasks = await service.getAllTasks();
|
||||
|
||||
expect(tasks[0]).toMatchObject({
|
||||
id: 'task-global-review',
|
||||
reviewState: 'review',
|
||||
kanbanColumn: 'review',
|
||||
});
|
||||
});
|
||||
|
||||
it('propagates leadSessionId for kanban-driven review transitions', async () => {
|
||||
const requestReviewMock = vi.fn();
|
||||
const approveReviewMock = vi.fn();
|
||||
|
|
@ -1585,20 +1775,23 @@ describe('TeamDataService', () => {
|
|||
|
||||
await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'review' });
|
||||
await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'approved' });
|
||||
await service.updateKanban('my-team', 'task-1', { op: 'request_changes', comment: 'Needs fixes' });
|
||||
await service.updateKanban('my-team', 'task-1', {
|
||||
op: 'request_changes',
|
||||
comment: 'Needs fixes',
|
||||
});
|
||||
|
||||
expect(requestReviewMock).toHaveBeenCalledWith('task-1', {
|
||||
from: 'user',
|
||||
from: 'lead',
|
||||
leadSessionId: 'lead-2',
|
||||
});
|
||||
expect(approveReviewMock).toHaveBeenCalledWith('task-1', {
|
||||
from: 'user',
|
||||
from: 'lead',
|
||||
suppressTaskComment: true,
|
||||
'notify-owner': true,
|
||||
leadSessionId: 'lead-2',
|
||||
});
|
||||
expect(requestChangesMock).toHaveBeenCalledWith('task-1', {
|
||||
from: 'user',
|
||||
from: 'lead',
|
||||
comment: 'Needs fixes',
|
||||
leadSessionId: 'lead-2',
|
||||
});
|
||||
|
|
@ -1615,10 +1808,12 @@ describe('TeamDataService', () => {
|
|||
ensureFile: vi.fn(async () => {
|
||||
journalExists = true;
|
||||
}),
|
||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}),
|
||||
withEntries: vi.fn(
|
||||
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -1707,10 +1902,12 @@ describe('TeamDataService', () => {
|
|||
const journal = {
|
||||
exists: vi.fn(async () => true),
|
||||
ensureFile: vi.fn(async () => undefined),
|
||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}),
|
||||
withEntries: vi.fn(
|
||||
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -1776,10 +1973,9 @@ describe('TeamDataService', () => {
|
|||
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
||||
})
|
||||
);
|
||||
const firstSendRequest = (inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } })
|
||||
.mock.calls[0]?.[1] as
|
||||
| { text?: string }
|
||||
| undefined;
|
||||
const firstSendRequest = (
|
||||
inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } }
|
||||
).mock.calls[0]?.[1] as { text?: string } | undefined;
|
||||
expect(String(firstSendRequest?.text ?? '')).not.toContain('<agent-block>');
|
||||
const sentEntry = journalEntries.find((entry) => entry.key === 'task-1:comment-1');
|
||||
expect(sentEntry).toMatchObject({
|
||||
|
|
@ -1803,10 +1999,12 @@ describe('TeamDataService', () => {
|
|||
ensureFile: vi.fn(async () => {
|
||||
journalExists = true;
|
||||
}),
|
||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}),
|
||||
withEntries: vi.fn(
|
||||
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -1902,10 +2100,12 @@ describe('TeamDataService', () => {
|
|||
const journal = {
|
||||
exists: vi.fn(async () => true),
|
||||
ensureFile: vi.fn(async () => undefined),
|
||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}),
|
||||
withEntries: vi.fn(
|
||||
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -1983,10 +2183,12 @@ describe('TeamDataService', () => {
|
|||
const journal = {
|
||||
exists: vi.fn(async () => true),
|
||||
ensureFile: vi.fn(async () => undefined),
|
||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}),
|
||||
withEntries: vi.fn(
|
||||
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -2081,15 +2283,20 @@ describe('TeamDataService', () => {
|
|||
},
|
||||
];
|
||||
const inboxWriter = {
|
||||
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'task-comment-forward:my-team:task-1:comment-1' })),
|
||||
sendMessage: vi.fn(async () => ({
|
||||
deliveredToInbox: true,
|
||||
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
||||
})),
|
||||
};
|
||||
const journal = {
|
||||
exists: vi.fn(async () => true),
|
||||
ensureFile: vi.fn(async () => undefined),
|
||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}),
|
||||
withEntries: vi.fn(
|
||||
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -2183,10 +2390,12 @@ describe('TeamDataService', () => {
|
|||
const journal = {
|
||||
exists: vi.fn(async () => true),
|
||||
ensureFile: vi.fn(async () => undefined),
|
||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}),
|
||||
withEntries: vi.fn(
|
||||
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -2283,10 +2492,12 @@ describe('TeamDataService', () => {
|
|||
const journal = {
|
||||
exists: vi.fn(async () => true),
|
||||
ensureFile: vi.fn(async () => undefined),
|
||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}),
|
||||
withEntries: vi.fn(
|
||||
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -2368,10 +2579,12 @@ describe('TeamDataService', () => {
|
|||
const journal = {
|
||||
exists: vi.fn(async () => true),
|
||||
ensureFile: vi.fn(async () => undefined),
|
||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}),
|
||||
withEntries: vi.fn(
|
||||
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -2611,15 +2824,19 @@ describe('TeamDataService', () => {
|
|||
const initGate = new Promise<void>((resolve) => {
|
||||
releaseInit = () => resolve();
|
||||
});
|
||||
const inboxWriter = { sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })) };
|
||||
const inboxWriter = {
|
||||
sendMessage: vi.fn(async () => ({ deliveredToInbox: true, messageId: 'msg-1' })),
|
||||
};
|
||||
const journalEntries: Array<Record<string, unknown>> = [];
|
||||
const journal = {
|
||||
exists: vi.fn(async () => true),
|
||||
ensureFile: vi.fn(async () => undefined),
|
||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}),
|
||||
withEntries: vi.fn(
|
||||
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||
const outcome = await fn(journalEntries);
|
||||
return outcome.result;
|
||||
}
|
||||
),
|
||||
};
|
||||
|
||||
try {
|
||||
|
|
@ -3420,7 +3637,9 @@ describe('TeamDataService', () => {
|
|||
});
|
||||
|
||||
const feed = await service.getMessageFeed('my-team');
|
||||
const linked = feed.messages.find((message) => message.messageId === 'passive-user-summary-old-1');
|
||||
const linked = feed.messages.find(
|
||||
(message) => message.messageId === 'passive-user-summary-old-1'
|
||||
);
|
||||
|
||||
expect(linked?.relayOfMessageId).toBeUndefined();
|
||||
});
|
||||
|
|
@ -3988,10 +4207,7 @@ describe('TeamDataService', () => {
|
|||
),
|
||||
]);
|
||||
|
||||
const firstSpy = vi.spyOn(
|
||||
firstService as never,
|
||||
'extractLeadAssistantTextsFromJsonl' as never
|
||||
);
|
||||
const firstSpy = vi.spyOn(firstService as never, 'extractLeadAssistantTextsFromJsonl' as never);
|
||||
const secondSpy = vi.spyOn(
|
||||
secondService as never,
|
||||
'extractLeadAssistantTextsFromJsonl' as never
|
||||
|
|
@ -4106,7 +4322,9 @@ describe('TeamDataService', () => {
|
|||
const service = createResolverBackedService();
|
||||
|
||||
const page = await service.getMessagesPage(fixture.teamName, { limit: 20 });
|
||||
const leadSessionMessages = page.messages.filter((message) => message.source === 'lead_session');
|
||||
const leadSessionMessages = page.messages.filter(
|
||||
(message) => message.source === 'lead_session'
|
||||
);
|
||||
|
||||
expect(
|
||||
leadSessionMessages.some((message) =>
|
||||
|
|
@ -4187,12 +4405,7 @@ describe('TeamDataService', () => {
|
|||
await flushMicrotasks();
|
||||
|
||||
expect(order).toEqual(
|
||||
expect.arrayContaining([
|
||||
'inboxNames:start',
|
||||
'meta:start',
|
||||
'kanban:start',
|
||||
'tasks:start',
|
||||
])
|
||||
expect.arrayContaining(['inboxNames:start', 'meta:start', 'kanban:start', 'tasks:start'])
|
||||
);
|
||||
expect(order).not.toContain('processes:start');
|
||||
expect(order).not.toContain('leadTexts:start');
|
||||
|
|
@ -4453,7 +4666,11 @@ describe('TeamDataService', () => {
|
|||
|
||||
const feed = await harness.service.getMessageFeed('my-team');
|
||||
|
||||
expect(feed.messages.map((message) => message.messageId)).toEqual(['sent-1', 'lead-1', 'inbox-1']);
|
||||
expect(feed.messages.map((message) => message.messageId)).toEqual([
|
||||
'sent-1',
|
||||
'lead-1',
|
||||
'inbox-1',
|
||||
]);
|
||||
});
|
||||
|
||||
it('preserves assembled messages and resolver inputs when inbox messages fail', async () => {
|
||||
|
|
@ -4571,10 +4788,12 @@ describe('TeamDataService', () => {
|
|||
},
|
||||
});
|
||||
|
||||
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(() => {
|
||||
order.push('leadTexts:start');
|
||||
throw new Error('lead sync fail');
|
||||
});
|
||||
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
|
||||
() => {
|
||||
order.push('leadTexts:start');
|
||||
throw new Error('lead sync fail');
|
||||
}
|
||||
);
|
||||
|
||||
const pending = harness.service.getTeamData('my-team');
|
||||
await flushMicrotasks();
|
||||
|
|
@ -4665,7 +4884,16 @@ describe('TeamDataService', () => {
|
|||
});
|
||||
|
||||
describe('getMessagesPage', () => {
|
||||
function createPaginationService(messages: Array<{ from: string; text: string; timestamp: string; messageId?: string; source?: string; leadSessionId?: string }>) {
|
||||
function createPaginationService(
|
||||
messages: Array<{
|
||||
from: string;
|
||||
text: string;
|
||||
timestamp: string;
|
||||
messageId?: string;
|
||||
source?: string;
|
||||
leadSessionId?: string;
|
||||
}>
|
||||
) {
|
||||
return new TeamDataService(
|
||||
{
|
||||
listTeams: vi.fn(),
|
||||
|
|
@ -4678,17 +4906,17 @@ describe('TeamDataService', () => {
|
|||
{ getTasks: vi.fn(async () => []) } as never,
|
||||
{
|
||||
listInboxNames: vi.fn(async () => []),
|
||||
getMessages: vi.fn(async () =>
|
||||
messages.map((m) => ({ ...m, read: true }))
|
||||
),
|
||||
getMessages: vi.fn(async () => messages.map((m) => ({ ...m, read: true }))),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ resolveMembers: vi.fn(() => []) } as never,
|
||||
{ getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })) } as never,
|
||||
{
|
||||
getState: vi.fn(async () => ({ teamName: 'my-team', reviewers: [], tasks: {} })),
|
||||
} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{ readMessages: vi.fn(async () => []) } as never,
|
||||
{ readMessages: vi.fn(async () => []) } as never
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -4788,7 +5016,9 @@ describe('TeamDataService', () => {
|
|||
expect(page1.messages[0]?.messageId).toMatch(/^inbox-/);
|
||||
expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!);
|
||||
expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true);
|
||||
expect(new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size).toBe(3);
|
||||
expect(
|
||||
new Set([...page1.messages, ...page2.messages].map((message) => message.messageId)).size
|
||||
).toBe(3);
|
||||
});
|
||||
|
||||
it('dedups newest-page live overlay against durable lead thoughts that already paged off the first page', async () => {
|
||||
|
|
@ -4872,7 +5102,10 @@ describe('TeamDataService', () => {
|
|||
cursor: page1.nextCursor!,
|
||||
});
|
||||
|
||||
expect(page2.messages.map((message) => message.messageId)).toEqual(['durable-2', 'durable-1']);
|
||||
expect(page2.messages.map((message) => message.messageId)).toEqual([
|
||||
'durable-2',
|
||||
'durable-1',
|
||||
]);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
normalizePersistedLaunchSnapshot,
|
||||
snapshotToMemberSpawnStatuses,
|
||||
summarizePersistedLaunchMembers,
|
||||
} from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
||||
|
|
@ -54,12 +55,13 @@ describe('TeamLaunchStateEvaluator', () => {
|
|||
});
|
||||
expect(statuses.bob).toMatchObject({
|
||||
launchState: 'runtime_pending_permission',
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
runtimeAlive: false,
|
||||
pendingPermissionRequestIds: ['req-1'],
|
||||
});
|
||||
});
|
||||
|
||||
it('counts persisted members in launch summary even when expectedMembers is stale', () => {
|
||||
it('does not count weak persisted runtimeAlive without strong liveness evidence', () => {
|
||||
const summary = summarizePersistedLaunchMembers(['alice'], {
|
||||
alice: {
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
|
|
@ -75,7 +77,7 @@ describe('TeamLaunchStateEvaluator', () => {
|
|||
confirmedCount: 0,
|
||||
pendingCount: 2,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
shellOnlyPendingCount: 0,
|
||||
runtimeProcessPendingCount: 0,
|
||||
runtimeCandidatePendingCount: 0,
|
||||
|
|
@ -83,4 +85,78 @@ describe('TeamLaunchStateEvaluator', () => {
|
|||
permissionPendingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('counts registered-only persisted liveness as no-runtime pending', () => {
|
||||
const summary = summarizePersistedLaunchMembers(['alice'], {
|
||||
alice: {
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
livenessKind: 'registered_only',
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
pendingCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
noRuntimePendingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('preserves persisted runtimeAlive only with strong liveness evidence', () => {
|
||||
const summary = summarizePersistedLaunchMembers(['alice', 'bob', 'cara'], {
|
||||
alice: {
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
livenessKind: 'runtime_process',
|
||||
},
|
||||
bob: {
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
},
|
||||
cara: {
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
},
|
||||
} as any);
|
||||
|
||||
expect(summary).toMatchObject({
|
||||
pendingCount: 3,
|
||||
runtimeAlivePendingCount: 2,
|
||||
runtimeCandidatePendingCount: 1,
|
||||
});
|
||||
});
|
||||
|
||||
it('normalizes stale persisted runtimeAlive to false without strong liveness evidence', () => {
|
||||
const snapshot = normalizePersistedLaunchSnapshot('demo', {
|
||||
version: 2,
|
||||
teamName: 'demo',
|
||||
updatedAt: '2026-04-23T00:00:00.000Z',
|
||||
launchPhase: 'active',
|
||||
expectedMembers: ['alice'],
|
||||
members: {
|
||||
alice: {
|
||||
name: 'alice',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
sources: {
|
||||
processAlive: true,
|
||||
},
|
||||
lastEvaluatedAt: '2026-04-23T00:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(snapshot?.members.alice).toMatchObject({
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
});
|
||||
expect(snapshot?.members.alice.sources?.processAlive).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,28 +0,0 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV,
|
||||
resolveTeamMemberLivenessModeFromEnv,
|
||||
} from '@main/services/team/TeamMemberLivenessMode';
|
||||
|
||||
describe('resolveTeamMemberLivenessModeFromEnv', () => {
|
||||
it('defaults to diagnostics', () => {
|
||||
expect(resolveTeamMemberLivenessModeFromEnv({})).toBe('diagnostics');
|
||||
});
|
||||
|
||||
it('enables strict mode explicitly', () => {
|
||||
expect(
|
||||
resolveTeamMemberLivenessModeFromEnv({
|
||||
[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]: 'strict',
|
||||
})
|
||||
).toBe('strict');
|
||||
});
|
||||
|
||||
it('falls back to diagnostics for unknown values', () => {
|
||||
expect(
|
||||
resolveTeamMemberLivenessModeFromEnv({
|
||||
[CLAUDE_TEAM_MEMBER_LIVENESS_MODE_ENV]: 'yes',
|
||||
})
|
||||
).toBe('diagnostics');
|
||||
});
|
||||
});
|
||||
|
|
@ -752,6 +752,71 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('keeps RSS visible for bootstrap-confirmed Anthropic teammates with a verified process', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
getConfig: vi.fn(async () => ({
|
||||
members: [
|
||||
{ name: 'team-lead', agentType: 'team-lead' },
|
||||
{ name: 'alice', providerId: 'anthropic', model: 'claude-sonnet-4-6' },
|
||||
],
|
||||
})),
|
||||
};
|
||||
(svc as any).readPersistedRuntimeMembers = vi.fn(() => [
|
||||
{
|
||||
name: 'alice',
|
||||
agentId: 'alice@nice-team',
|
||||
backendType: 'tmux',
|
||||
},
|
||||
]);
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'nice-team',
|
||||
expectedMembers: ['alice'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'alice',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
lastHeartbeatAt: '2026-04-24T12:00:00.000Z',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
run.child = { pid: 111 };
|
||||
run.request = { model: 'claude-opus-4-6' };
|
||||
run.processKilled = false;
|
||||
run.cancelRequested = false;
|
||||
(svc as any).aliveRunByTeam.set('nice-team', run.runId);
|
||||
(svc as any).runs.set(run.runId, run);
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
|
||||
{
|
||||
pid: 333,
|
||||
ppid: 1,
|
||||
command:
|
||||
'/Users/belief/.bun/bin/bun cli.js --agent-id alice@nice-team --agent-name alice --team-name nice-team --model claude-sonnet-4-6',
|
||||
},
|
||||
]);
|
||||
vi.mocked(pidusage).mockResolvedValueOnce({
|
||||
'111': createPidusageStat(111, 123_000_000),
|
||||
'333': createPidusageStat(333, 456_000_000),
|
||||
} as any);
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot('nice-team');
|
||||
|
||||
expect(pidusage).toHaveBeenCalledWith([111, 333], { maxage: 0 });
|
||||
expect(snapshot.members.alice).toMatchObject({
|
||||
alive: true,
|
||||
providerId: 'anthropic',
|
||||
pid: 333,
|
||||
pidSource: 'agent_process_table',
|
||||
rssBytes: 456_000_000,
|
||||
runtimeModel: 'claude-sonnet-4-6',
|
||||
});
|
||||
});
|
||||
|
||||
it('prefers the newest matching agent pid when multiple processes match the same teammate', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
|
|
@ -836,8 +901,8 @@ describe('TeamProvisioningService', () => {
|
|||
expect(snapshot.members.alice).toBeUndefined();
|
||||
});
|
||||
|
||||
it('keeps pure OpenCode launch members alive from confirmed launch snapshot while runtime adapter is tracked', async () => {
|
||||
const teamName = 'pure-opencode-runtime-team';
|
||||
it('keeps historical bootstrap separate from current runtime liveness', async () => {
|
||||
const teamName = 'pure-opencode-runtime-team-strict';
|
||||
const projectPath = '/Users/test/project';
|
||||
writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']);
|
||||
writeLaunchState(teamName, 'lead-session', {
|
||||
|
|
@ -864,12 +929,102 @@ describe('TeamProvisioningService', () => {
|
|||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
|
||||
expect(snapshot.members.alice).toMatchObject({
|
||||
alive: true,
|
||||
alive: false,
|
||||
historicalBootstrapConfirmed: true,
|
||||
providerId: 'opencode',
|
||||
runtimeModel: 'opencode/big-pickle',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not treat a reused OpenCode runtime pid as live', async () => {
|
||||
const teamName = 'pure-opencode-reused-pid-team';
|
||||
const projectPath = '/Users/test/project';
|
||||
writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']);
|
||||
writeLaunchState(teamName, 'lead-session', {
|
||||
alice: {
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/big-pickle',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
runtimePid: 333,
|
||||
runtimeSessionId: 'session-alice',
|
||||
},
|
||||
});
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValueOnce([
|
||||
{ pid: 333, ppid: 1, command: 'node unrelated-worker.js' },
|
||||
]);
|
||||
vi.mocked(pidusage).mockResolvedValueOnce({
|
||||
'333': createPidusageStat(333, 456_000_000),
|
||||
} as any);
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).runtimeAdapterRunByTeam.set(teamName, {
|
||||
runId: 'opencode-runtime-run',
|
||||
providerId: 'opencode',
|
||||
cwd: projectPath,
|
||||
});
|
||||
(svc as any).aliveRunByTeam.set(teamName, 'opencode-runtime-run');
|
||||
|
||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||
|
||||
expect(snapshot.members.alice).toMatchObject({
|
||||
alive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
pidSource: 'opencode_bridge',
|
||||
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
|
||||
pid: 333,
|
||||
providerId: 'opencode',
|
||||
});
|
||||
});
|
||||
|
||||
it('does not carry stale persisted runtimeAlive through launch-state reconcile', async () => {
|
||||
const teamName = 'persisted-stale-runtime-status-team';
|
||||
const projectPath = '/Users/test/project';
|
||||
const acceptedAt = new Date(Date.now() - 120_000).toISOString();
|
||||
writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']);
|
||||
writeLaunchState(teamName, 'lead-session', {
|
||||
alice: {
|
||||
providerId: 'codex',
|
||||
model: 'gpt-5.4',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
hardFailureReason: undefined,
|
||||
firstSpawnAcceptedAt: acceptedAt,
|
||||
runtimePid: 333,
|
||||
livenessKind: 'runtime_process',
|
||||
pidSource: 'agent_process_table',
|
||||
},
|
||||
});
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
|
||||
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||
const persisted = JSON.parse(fs.readFileSync(getTeamLaunchStatePath(teamName), 'utf8'));
|
||||
|
||||
expect(result.statuses.alice).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
livenessSource: undefined,
|
||||
livenessKind: 'stale_metadata',
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate did not join within the launch grace window.',
|
||||
});
|
||||
expect(result.summary).toMatchObject({
|
||||
failedCount: 1,
|
||||
runtimeAlivePendingCount: 0,
|
||||
});
|
||||
expect(persisted.members.alice.runtimeAlive).toBe(false);
|
||||
expect(persisted.members.alice.sources?.processAlive).toBeUndefined();
|
||||
});
|
||||
|
||||
it('excludes removed meta members from live runtime metadata resolution', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).configReader = {
|
||||
|
|
@ -1140,6 +1295,9 @@ describe('TeamProvisioningService', () => {
|
|||
];
|
||||
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
|
||||
(svc as any).runs.set('run-1', run);
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([
|
||||
{ pid: 333, ppid: 1, command: 'opencode runtime host' },
|
||||
]);
|
||||
vi.mocked(pidusage).mockReset();
|
||||
vi.mocked(pidusage).mockImplementation(
|
||||
async (target: number | string | Array<number | string>) => {
|
||||
|
|
@ -1216,6 +1374,9 @@ describe('TeamProvisioningService', () => {
|
|||
),
|
||||
};
|
||||
vi.mocked(pidusage).mockReset();
|
||||
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([
|
||||
{ pid: 333, ppid: 1, command: 'opencode runtime host' },
|
||||
]);
|
||||
vi.mocked(pidusage).mockImplementation(
|
||||
async (target: number | string | Array<number | string>) => {
|
||||
if (Array.isArray(target)) {
|
||||
|
|
@ -2238,6 +2399,7 @@ describe('TeamProvisioningService', () => {
|
|||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeProcessPendingCount: 1,
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
|
|
@ -2314,6 +2476,37 @@ describe('TeamProvisioningService', () => {
|
|||
).toBe('Finishing launch — 1 teammate awaiting permission approval');
|
||||
});
|
||||
|
||||
it('counts registered-only liveness as no-runtime pending in launch summaries', () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
expectedMembers: ['alice'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'alice',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
livenessKind: 'registered_only',
|
||||
runtimeDiagnostic: 'registered runtime metadata without live process',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
|
||||
const launchSummary = (svc as any).getMemberLaunchSummary(run);
|
||||
|
||||
expect(launchSummary).toMatchObject({
|
||||
pendingCount: 1,
|
||||
noRuntimePendingCount: 1,
|
||||
});
|
||||
expect(
|
||||
(svc as any).buildPendingBootstrapStatusMessage('Finishing launch', run, launchSummary)
|
||||
).toContain('1 no runtime found');
|
||||
});
|
||||
|
||||
it('trusts persisted snapshot permission state for pure teams when live run statuses are absent', () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
|
|
@ -2330,6 +2523,7 @@ describe('TeamProvisioningService', () => {
|
|||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeProcessPendingCount: 1,
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
|
|
@ -2383,6 +2577,7 @@ describe('TeamProvisioningService', () => {
|
|||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeProcessPendingCount: 1,
|
||||
},
|
||||
{
|
||||
version: 2,
|
||||
|
|
@ -2403,6 +2598,7 @@ describe('TeamProvisioningService', () => {
|
|||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
livenessKind: 'runtime_process',
|
||||
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
|
|
@ -2411,6 +2607,7 @@ describe('TeamProvisioningService', () => {
|
|||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
runtimeProcessPendingCount: 1,
|
||||
},
|
||||
teamLaunchState: 'partial_pending',
|
||||
}
|
||||
|
|
@ -2420,6 +2617,24 @@ describe('TeamProvisioningService', () => {
|
|||
expect(message).not.toContain('/0');
|
||||
});
|
||||
|
||||
it('does not use legacy runtimeAlivePendingCount as online launch copy evidence', () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'pure-team',
|
||||
expectedMembers: ['alice'],
|
||||
memberSpawnStatuses: new Map(),
|
||||
});
|
||||
|
||||
const message = (svc as any).buildAggregatePendingLaunchMessage('Finishing launch', run, {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
});
|
||||
|
||||
expect(message).toBe('Finishing launch — teammates are still starting');
|
||||
});
|
||||
|
||||
it('uses the union of persisted expected members and persisted member entries for pending launch copy', () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
|
|
@ -2987,6 +3202,82 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('persists sanitized runtime tool metadata diagnostics on OpenCode liveness updates', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const previousSnapshot = {
|
||||
version: 2 as const,
|
||||
teamName: 'mixed-team',
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
launchPhase: 'active' as const,
|
||||
expectedMembers: ['bob'],
|
||||
members: {
|
||||
bob: {
|
||||
name: 'bob',
|
||||
providerId: 'opencode' as const,
|
||||
laneId: 'secondary:opencode:bob',
|
||||
laneKind: 'secondary' as const,
|
||||
laneOwnerProviderId: 'opencode' as const,
|
||||
launchState: 'runtime_pending_bootstrap' as const,
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
||||
diagnostics: ['existing diagnostic'],
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 1,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 1,
|
||||
},
|
||||
teamLaunchState: 'partial_pending' as const,
|
||||
};
|
||||
const write = vi.fn(async () => {});
|
||||
|
||||
(svc as any).launchStateStore = {
|
||||
read: vi.fn(async () => previousSnapshot),
|
||||
write,
|
||||
};
|
||||
|
||||
await (svc as any).updateOpenCodeRuntimeMemberLiveness({
|
||||
teamName: 'mixed-team',
|
||||
runId: 'run-member-spawn-1',
|
||||
memberName: 'bob',
|
||||
runtimeSessionId: 'session-bob',
|
||||
observedAt: '2026-04-22T12:05:00.000Z',
|
||||
diagnostics: ['native heartbeat'],
|
||||
metadata: {
|
||||
runtimePid: 4321,
|
||||
processCommand: 'opencode runtime --token super-secret --safe ok',
|
||||
runtimeVersion: '1.2.3',
|
||||
hostPid: 987,
|
||||
cwd: '/tmp/project',
|
||||
},
|
||||
reason: 'OpenCode runtime heartbeat accepted',
|
||||
});
|
||||
|
||||
expect(write).toHaveBeenCalledTimes(1);
|
||||
const writtenSnapshot = (
|
||||
write.mock.calls[0] as unknown as [string, Record<string, unknown>] | undefined
|
||||
)?.[1] as { members?: Record<string, { diagnostics?: string[] }> } | undefined;
|
||||
const diagnostics = writtenSnapshot?.members?.bob?.diagnostics ?? [];
|
||||
expect(diagnostics).toEqual(
|
||||
expect.arrayContaining([
|
||||
'existing diagnostic',
|
||||
'native heartbeat',
|
||||
'runtime pid: 4321',
|
||||
'runtime process command: opencode runtime --token [redacted] --safe ok',
|
||||
'runtime version: 1.2.3',
|
||||
'runtime host pid: 987',
|
||||
'runtime cwd: /tmp/project',
|
||||
'OpenCode runtime heartbeat accepted',
|
||||
])
|
||||
);
|
||||
expect(diagnostics.join('\n')).not.toContain('super-secret');
|
||||
});
|
||||
|
||||
it('preserves richer persisted expectedMembers when OpenCode runtime liveness updates a stale snapshot', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const previousSnapshot = {
|
||||
|
|
@ -4356,6 +4647,107 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
expect(run.pendingMemberRestarts.has('bob')).toBe(false);
|
||||
});
|
||||
|
||||
it('does not let stale runtimeAlive bypass launch timeout when live metadata is weak', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'codex-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'process',
|
||||
bootstrapConfirmed: false,
|
||||
firstSpawnAcceptedAt: new Date(Date.now() - 120_000).toISOString(),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
(svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {});
|
||||
(svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {});
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
async () =>
|
||||
new Map([
|
||||
[
|
||||
'bob',
|
||||
{
|
||||
alive: false,
|
||||
livenessKind: 'shell_only',
|
||||
runtimeDiagnostic: 'tmux pane foreground command is zsh',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
await (svc as any).reevaluateMemberLaunchStatus(run, 'bob');
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
livenessSource: undefined,
|
||||
bootstrapConfirmed: false,
|
||||
livenessKind: 'shell_only',
|
||||
runtimeDiagnostic: 'tmux pane foreground command is zsh',
|
||||
error: 'tmux pane foreground command is zsh',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps verified runtime pending with a warning after the bootstrap stall window', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const run = createMemberSpawnRun({
|
||||
teamName: 'codex-team',
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
firstSpawnAcceptedAt: new Date(Date.now() - 6 * 60_000).toISOString(),
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
(svc as any).refreshMemberSpawnStatusesFromLeadInbox = vi.fn(async () => {});
|
||||
(svc as any).maybeAuditMemberSpawnStatuses = vi.fn(async () => {});
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
async () =>
|
||||
new Map([
|
||||
[
|
||||
'bob',
|
||||
{
|
||||
alive: true,
|
||||
livenessKind: 'runtime_process',
|
||||
runtimeDiagnostic: 'verified runtime process detected',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
await (svc as any).reevaluateMemberLaunchStatus(run, 'bob');
|
||||
|
||||
expect(run.memberSpawnStatuses.get('bob')).toMatchObject({
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: false,
|
||||
livenessSource: 'process',
|
||||
livenessKind: 'runtime_process',
|
||||
runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
hardFailure: false,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
it('removes generated MCP config when createTeam spawn fails synchronously', async () => {
|
||||
|
|
@ -5753,7 +6145,19 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['alice']));
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
async () =>
|
||||
new Map([
|
||||
[
|
||||
'alice',
|
||||
{
|
||||
alive: true,
|
||||
livenessKind: 'runtime_process',
|
||||
runtimeDiagnostic: 'verified runtime process detected',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||
|
||||
|
|
@ -6098,14 +6502,12 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['jack']));
|
||||
|
||||
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||
|
||||
expect(result.statuses.jack).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: true,
|
||||
});
|
||||
expect(result.statuses.jack?.error).toContain('requested model is not available');
|
||||
expect(result.statuses.jack?.hardFailureReason).toContain('requested model is not available');
|
||||
|
|
@ -6449,7 +6851,7 @@ describe('TeamProvisioningService', () => {
|
|||
expect(run.memberSpawnStatuses.get('alice')).toBe(existingEntry);
|
||||
});
|
||||
|
||||
it('treats duplicate_skipped already_running as process-confirmed online', () => {
|
||||
it('keeps duplicate_skipped already_running pending without strong evidence', () => {
|
||||
const run = createMemberSpawnRun();
|
||||
run.activeToolCalls.set('tool-agent-1', {
|
||||
memberName: 'alice',
|
||||
|
|
@ -6477,10 +6879,9 @@ describe('TeamProvisioningService', () => {
|
|||
);
|
||||
|
||||
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'process',
|
||||
runtimeAlive: false,
|
||||
hardFailure: false,
|
||||
});
|
||||
});
|
||||
|
|
@ -6629,6 +7030,7 @@ describe('TeamProvisioningService', () => {
|
|||
{
|
||||
alive: true,
|
||||
model: 'gpt-5.2',
|
||||
livenessKind: 'runtime_process',
|
||||
},
|
||||
],
|
||||
])
|
||||
|
|
@ -6666,6 +7068,7 @@ describe('TeamProvisioningService', () => {
|
|||
{
|
||||
alive: true,
|
||||
model: 'gpt-5.2',
|
||||
livenessKind: 'runtime_process',
|
||||
},
|
||||
],
|
||||
])
|
||||
|
|
@ -6693,6 +7096,130 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('downgrades stale process liveness to pending when live metadata is weak', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
async () =>
|
||||
new Map([
|
||||
[
|
||||
'bob',
|
||||
{
|
||||
alive: false,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', {
|
||||
bob: createMemberSpawnStatusEntry({
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'process',
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.bob).toMatchObject({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
livenessSource: undefined,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps process table diagnostics visible when live metadata has no primary diagnostic', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
async () =>
|
||||
new Map([
|
||||
[
|
||||
'bob',
|
||||
{
|
||||
alive: false,
|
||||
livenessKind: 'not_found',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
diagnostics: ['process table is unavailable'],
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = await (svc as any).attachLiveRuntimeMetadataToStatuses('beacon-desk-4', {
|
||||
bob: createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
}),
|
||||
});
|
||||
|
||||
expect(result.bob).toMatchObject({
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
livenessKind: 'not_found',
|
||||
runtimeDiagnostic: 'process table unavailable',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
});
|
||||
});
|
||||
|
||||
it('classifies process table unavailable launch diagnostics with natural wording', () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
const onProgress = vi.fn();
|
||||
const run = createMemberSpawnRun({
|
||||
expectedMembers: ['bob'],
|
||||
memberSpawnStatuses: new Map([
|
||||
[
|
||||
'bob',
|
||||
createMemberSpawnStatusEntry({
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
livenessKind: 'shell_only',
|
||||
runtimeDiagnostic: 'tmux pane foreground command is zsh; process table is unavailable',
|
||||
}),
|
||||
],
|
||||
]),
|
||||
});
|
||||
run.isLaunch = true;
|
||||
run.progress = {
|
||||
runId: run.runId,
|
||||
teamName: run.teamName,
|
||||
status: 'running',
|
||||
updatedAt: '2026-04-22T12:00:00.000Z',
|
||||
};
|
||||
run.onProgress = onProgress;
|
||||
|
||||
(svc as any).setMemberSpawnStatus(run, 'bob', 'online', undefined, 'process');
|
||||
|
||||
expect(run.progress.launchDiagnostics).toEqual(
|
||||
expect.arrayContaining([
|
||||
expect.objectContaining({
|
||||
memberName: 'bob',
|
||||
code: 'process_table_unavailable',
|
||||
severity: 'warning',
|
||||
detail: 'tmux pane foreground command is zsh; process table is unavailable',
|
||||
}),
|
||||
])
|
||||
);
|
||||
expect(onProgress).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
launchDiagnostics: expect.arrayContaining([
|
||||
expect.objectContaining({ code: 'process_table_unavailable' }),
|
||||
]),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it('does not clear an explicit restart failure just because the old runtime is still alive', async () => {
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
|
|
@ -6803,7 +7330,6 @@ describe('TeamProvisioningService', () => {
|
|||
expect(result.bob).toMatchObject({
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: true,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'Teammate did not join within the launch grace window.',
|
||||
error: 'Teammate did not join within the launch grace window.',
|
||||
|
|
@ -7022,7 +7548,7 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('treats suffixed live runtime names as alive during persisted launch reconcile', async () => {
|
||||
it('keeps suffixed weak runtime metadata pending during persisted launch reconcile', async () => {
|
||||
const teamName = 'suffixed-live-runtime-team';
|
||||
const leadSessionId = 'lead-session';
|
||||
writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['alice']);
|
||||
|
|
@ -7039,14 +7565,26 @@ describe('TeamProvisioningService', () => {
|
|||
});
|
||||
|
||||
const svc = new TeamProvisioningService();
|
||||
(svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set(['alice-2']));
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||
async () =>
|
||||
new Map([
|
||||
[
|
||||
'alice-2',
|
||||
{
|
||||
alive: false,
|
||||
livenessKind: 'registered_only',
|
||||
runtimeDiagnostic: 'registered runtime metadata without live process',
|
||||
},
|
||||
],
|
||||
])
|
||||
);
|
||||
|
||||
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||
|
||||
expect(result.statuses.alice).toMatchObject({
|
||||
status: 'online',
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
runtimeAlive: false,
|
||||
});
|
||||
});
|
||||
|
||||
|
|
@ -7105,7 +7643,7 @@ describe('TeamProvisioningService', () => {
|
|||
2
|
||||
)
|
||||
);
|
||||
(svc as any).getLiveTeamAgentNames = vi.fn(async () => new Set<string>());
|
||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(async () => new Map());
|
||||
|
||||
const result = await (svc as any).reconcilePersistedLaunchState(teamName);
|
||||
|
||||
|
|
|
|||
181
test/main/services/team/TeamRuntimeLivenessResolver.test.ts
Normal file
181
test/main/services/team/TeamRuntimeLivenessResolver.test.ts
Normal file
|
|
@ -0,0 +1,181 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
resolveTeamMemberRuntimeLiveness,
|
||||
sanitizeProcessCommandForDiagnostics,
|
||||
} from '@main/services/team/TeamRuntimeLivenessResolver';
|
||||
|
||||
const NOW = '2026-04-24T12:00:00.000Z';
|
||||
|
||||
describe('resolveTeamMemberRuntimeLiveness', () => {
|
||||
it('classifies tmux shell panes as weak shell-only evidence', () => {
|
||||
const result = resolveTeamMemberRuntimeLiveness({
|
||||
teamName: 'demo',
|
||||
memberName: 'bob',
|
||||
agentId: 'agent-bob',
|
||||
backendType: 'tmux',
|
||||
tmuxPaneId: '%1',
|
||||
pane: { paneId: '%1', panePid: 100, currentCommand: 'zsh' },
|
||||
processRows: [{ pid: 100, ppid: 1, command: 'zsh' }],
|
||||
processTableAvailable: true,
|
||||
nowIso: NOW,
|
||||
});
|
||||
|
||||
expect(result.alive).toBe(false);
|
||||
expect(result.livenessKind).toBe('shell_only');
|
||||
expect(result.pidSource).toBe('tmux_pane');
|
||||
expect(result.pid).toBe(100);
|
||||
});
|
||||
|
||||
it('promotes a verified team and agent process to strong runtime evidence', () => {
|
||||
const result = resolveTeamMemberRuntimeLiveness({
|
||||
teamName: 'demo',
|
||||
memberName: 'alice',
|
||||
agentId: 'agent-alice',
|
||||
backendType: 'tmux',
|
||||
processRows: [
|
||||
{
|
||||
pid: 222,
|
||||
ppid: 1,
|
||||
command: 'node runtime --team-name demo --agent-id agent-alice',
|
||||
},
|
||||
],
|
||||
processTableAvailable: true,
|
||||
nowIso: NOW,
|
||||
});
|
||||
|
||||
expect(result.alive).toBe(true);
|
||||
expect(result.livenessKind).toBe('runtime_process');
|
||||
expect(result.pidSource).toBe('agent_process_table');
|
||||
expect(result.pid).toBe(222);
|
||||
});
|
||||
|
||||
it('keeps a verified process pid visible after bootstrap is confirmed', () => {
|
||||
const result = resolveTeamMemberRuntimeLiveness({
|
||||
teamName: 'demo',
|
||||
memberName: 'alice',
|
||||
agentId: 'agent-alice',
|
||||
backendType: 'tmux',
|
||||
trackedSpawnStatus: {
|
||||
status: 'online',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
updatedAt: NOW,
|
||||
},
|
||||
processRows: [
|
||||
{
|
||||
pid: 222,
|
||||
ppid: 1,
|
||||
command: 'node runtime --team-name demo --agent-id agent-alice',
|
||||
},
|
||||
],
|
||||
processTableAvailable: true,
|
||||
nowIso: NOW,
|
||||
});
|
||||
|
||||
expect(result.alive).toBe(true);
|
||||
expect(result.livenessKind).toBe('runtime_process');
|
||||
expect(result.pidSource).toBe('agent_process_table');
|
||||
expect(result.pid).toBe(222);
|
||||
});
|
||||
|
||||
it('keeps a non-shell tmux descendant without identity as a candidate', () => {
|
||||
const result = resolveTeamMemberRuntimeLiveness({
|
||||
teamName: 'demo',
|
||||
memberName: 'jack',
|
||||
agentId: 'agent-jack',
|
||||
backendType: 'tmux',
|
||||
tmuxPaneId: '%2',
|
||||
pane: { paneId: '%2', panePid: 300, currentCommand: 'zsh' },
|
||||
processRows: [
|
||||
{ pid: 300, ppid: 1, command: 'zsh' },
|
||||
{ pid: 301, ppid: 300, command: 'node helper.js' },
|
||||
],
|
||||
processTableAvailable: true,
|
||||
nowIso: NOW,
|
||||
});
|
||||
|
||||
expect(result.alive).toBe(false);
|
||||
expect(result.livenessKind).toBe('runtime_process_candidate');
|
||||
expect(result.pidSource).toBe('tmux_child');
|
||||
expect(result.pid).toBe(301);
|
||||
});
|
||||
|
||||
it('promotes a live OpenCode runtime pid only when process identity matches', () => {
|
||||
const result = resolveTeamMemberRuntimeLiveness({
|
||||
teamName: 'demo',
|
||||
memberName: 'bob',
|
||||
providerId: 'opencode',
|
||||
persistedRuntimePid: 404,
|
||||
persistedRuntimeSessionId: 'session-bob',
|
||||
processRows: [{ pid: 404, ppid: 1, command: 'opencode runtime host' }],
|
||||
processTableAvailable: true,
|
||||
nowIso: NOW,
|
||||
});
|
||||
|
||||
expect(result.alive).toBe(true);
|
||||
expect(result.livenessKind).toBe('runtime_process');
|
||||
expect(result.pidSource).toBe('opencode_bridge');
|
||||
expect(result.pid).toBe(404);
|
||||
});
|
||||
|
||||
it('does not trust an OpenCode runtime pid reused by an unrelated process', () => {
|
||||
const result = resolveTeamMemberRuntimeLiveness({
|
||||
teamName: 'demo',
|
||||
memberName: 'bob',
|
||||
providerId: 'opencode',
|
||||
persistedRuntimePid: 404,
|
||||
persistedRuntimeSessionId: 'session-bob',
|
||||
processRows: [{ pid: 404, ppid: 1, command: 'node unrelated-worker.js' }],
|
||||
processTableAvailable: true,
|
||||
nowIso: NOW,
|
||||
});
|
||||
|
||||
expect(result.alive).toBe(false);
|
||||
expect(result.livenessKind).toBe('runtime_process_candidate');
|
||||
expect(result.pidSource).toBe('opencode_bridge');
|
||||
expect(result.runtimeDiagnostic).toBe(
|
||||
'OpenCode runtime pid is alive, but process identity is unverified'
|
||||
);
|
||||
});
|
||||
|
||||
it('does not trust a stale persisted pid without current process identity', () => {
|
||||
const result = resolveTeamMemberRuntimeLiveness({
|
||||
teamName: 'demo',
|
||||
memberName: 'tom',
|
||||
persistedRuntimePid: 444,
|
||||
processRows: [{ pid: 555, ppid: 1, command: 'node other.js' }],
|
||||
processTableAvailable: true,
|
||||
nowIso: NOW,
|
||||
});
|
||||
|
||||
expect(result.alive).toBe(false);
|
||||
expect(result.livenessKind).toBe('stale_metadata');
|
||||
expect(result.pidSource).toBe('persisted_metadata');
|
||||
});
|
||||
|
||||
it('does not treat a persisted pid as stale when the process table is unavailable', () => {
|
||||
const result = resolveTeamMemberRuntimeLiveness({
|
||||
teamName: 'demo',
|
||||
memberName: 'tom',
|
||||
persistedRuntimePid: 444,
|
||||
processRows: [],
|
||||
processTableAvailable: false,
|
||||
nowIso: NOW,
|
||||
});
|
||||
|
||||
expect(result.alive).toBe(false);
|
||||
expect(result.livenessKind).toBe('registered_only');
|
||||
expect(result.pidSource).toBe('persisted_metadata');
|
||||
expect(result.diagnostics).toContain('process table unavailable');
|
||||
});
|
||||
|
||||
it('redacts common secret flags in diagnostics commands', () => {
|
||||
expect(
|
||||
sanitizeProcessCommandForDiagnostics('node runtime --api-key sk-123 --token=abc --safe ok')
|
||||
).toBe('node runtime --api-key [redacted] --token=[redacted] --safe ok');
|
||||
});
|
||||
});
|
||||
303
test/main/services/team/TeamRuntimeMemory.safe-e2e.test.ts
Normal file
303
test/main/services/team/TeamRuntimeMemory.safe-e2e.test.ts
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
import { spawn, type ChildProcess } from 'node:child_process';
|
||||
import { existsSync } from 'node:fs';
|
||||
import { promises as fs } from 'node:fs';
|
||||
import * as os from 'node:os';
|
||||
import * as path from 'node:path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it } from 'vitest';
|
||||
|
||||
import { TeamProvisioningService } from '../../../../src/main/services/team/TeamProvisioningService';
|
||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||
|
||||
describe('Team runtime memory safe e2e', () => {
|
||||
let tempDir: string;
|
||||
let child: ChildProcess | null;
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-runtime-memory-e2e-'));
|
||||
await fs.mkdir(path.join(tempDir, '.claude'), { recursive: true });
|
||||
setClaudeBasePathOverride(path.join(tempDir, '.claude'));
|
||||
child = null;
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
if (child?.pid) {
|
||||
child.kill('SIGTERM');
|
||||
await waitForExit(child, 2_000).catch(() => {
|
||||
if (child?.pid) child.kill('SIGKILL');
|
||||
});
|
||||
}
|
||||
setClaudeBasePathOverride(null);
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('reports RSS for a bootstrap-confirmed Anthropic teammate discovered from the real process table', async () => {
|
||||
const teamName = `anthropic-rss-${process.pid}`;
|
||||
const memberName = 'alice';
|
||||
const agentId = `${memberName}@${teamName}`;
|
||||
const projectPath = path.join(tempDir, 'project');
|
||||
const runtimeScriptPath = path.join(tempDir, 'anthropic-runtime-fixture.mjs');
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await fs.writeFile(
|
||||
runtimeScriptPath,
|
||||
[
|
||||
'const keepAlive = setInterval(() => {}, 1000);',
|
||||
"process.on('SIGTERM', () => { clearInterval(keepAlive); process.exit(0); });",
|
||||
].join('\n'),
|
||||
'utf8'
|
||||
);
|
||||
await writeTeamFixture({
|
||||
tempDir,
|
||||
teamName,
|
||||
projectPath,
|
||||
memberName,
|
||||
agentId,
|
||||
});
|
||||
|
||||
child = spawn(
|
||||
process.execPath,
|
||||
[
|
||||
runtimeScriptPath,
|
||||
'--agent-id',
|
||||
agentId,
|
||||
'--agent-name',
|
||||
memberName,
|
||||
'--team-name',
|
||||
teamName,
|
||||
'--model',
|
||||
'claude-sonnet-4-6',
|
||||
],
|
||||
{
|
||||
cwd: projectPath,
|
||||
stdio: 'ignore',
|
||||
}
|
||||
);
|
||||
expect(child.pid).toEqual(expect.any(Number));
|
||||
await waitForProcessCommand(child.pid!, agentId, teamName);
|
||||
|
||||
const snapshot = await new TeamProvisioningService().getTeamAgentRuntimeSnapshot(teamName);
|
||||
|
||||
expect(snapshot.members[memberName]).toMatchObject({
|
||||
alive: true,
|
||||
providerId: 'anthropic',
|
||||
pid: child.pid,
|
||||
pidSource: 'agent_process_table',
|
||||
livenessKind: 'runtime_process',
|
||||
runtimeModel: 'claude-sonnet-4-6',
|
||||
historicalBootstrapConfirmed: true,
|
||||
});
|
||||
expect(snapshot.members[memberName]?.rssBytes).toEqual(expect.any(Number));
|
||||
expect(snapshot.members[memberName]?.rssBytes).toBeGreaterThan(0);
|
||||
});
|
||||
|
||||
const cliSmokeIt =
|
||||
process.env.ANTHROPIC_RUNTIME_MEMORY_CLI_SMOKE === '1' &&
|
||||
process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH?.trim() &&
|
||||
existsSync(process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH.trim())
|
||||
? it
|
||||
: it.skip;
|
||||
|
||||
cliSmokeIt('reports RSS for a real Anthropic teammate CLI process', async () => {
|
||||
const teamName = `anthropic-cli-rss-${process.pid}`;
|
||||
const memberName = 'alice';
|
||||
const agentId = `${memberName}@${teamName}`;
|
||||
const projectPath = path.join(tempDir, 'project');
|
||||
const cliPath = process.env.CLAUDE_AGENT_TEAMS_ORCHESTRATOR_CLI_PATH!.trim();
|
||||
await fs.mkdir(projectPath, { recursive: true });
|
||||
await writeTeamFixture({
|
||||
tempDir,
|
||||
teamName,
|
||||
projectPath,
|
||||
memberName,
|
||||
agentId,
|
||||
});
|
||||
|
||||
let stderrTail = '';
|
||||
child = spawn(
|
||||
cliPath,
|
||||
[
|
||||
'--agent-id',
|
||||
agentId,
|
||||
'--agent-name',
|
||||
memberName,
|
||||
'--team-name',
|
||||
teamName,
|
||||
'--model',
|
||||
'claude-sonnet-4-6',
|
||||
],
|
||||
{
|
||||
cwd: projectPath,
|
||||
env: {
|
||||
...process.env,
|
||||
CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS: '1',
|
||||
NO_UPDATE_NOTIFIER: '1',
|
||||
},
|
||||
stdio: ['pipe', 'ignore', 'pipe'],
|
||||
}
|
||||
);
|
||||
child.stderr?.on('data', (chunk) => {
|
||||
stderrTail = `${stderrTail}${String(chunk)}`.slice(-4_000);
|
||||
});
|
||||
expect(child.pid).toEqual(expect.any(Number));
|
||||
await waitForProcessCommand(child.pid!, agentId, teamName, () => stderrTail);
|
||||
|
||||
const snapshot = await new TeamProvisioningService().getTeamAgentRuntimeSnapshot(teamName);
|
||||
|
||||
expect(snapshot.members[memberName]).toMatchObject({
|
||||
alive: true,
|
||||
providerId: 'anthropic',
|
||||
pid: child.pid,
|
||||
pidSource: 'agent_process_table',
|
||||
livenessKind: 'runtime_process',
|
||||
runtimeModel: 'claude-sonnet-4-6',
|
||||
historicalBootstrapConfirmed: true,
|
||||
});
|
||||
expect(snapshot.members[memberName]?.rssBytes).toEqual(expect.any(Number));
|
||||
expect(snapshot.members[memberName]?.rssBytes).toBeGreaterThan(0);
|
||||
});
|
||||
});
|
||||
|
||||
async function writeTeamFixture(params: {
|
||||
tempDir: string;
|
||||
teamName: string;
|
||||
projectPath: string;
|
||||
memberName: string;
|
||||
agentId: string;
|
||||
}): Promise<void> {
|
||||
const teamDir = path.join(params.tempDir, '.claude', 'teams', params.teamName);
|
||||
await fs.mkdir(teamDir, { recursive: true });
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'config.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
name: params.teamName,
|
||||
projectPath: params.projectPath,
|
||||
leadSessionId: 'lead-session',
|
||||
members: [
|
||||
{
|
||||
name: 'team-lead',
|
||||
agentType: 'team-lead',
|
||||
role: 'Lead',
|
||||
providerId: 'anthropic',
|
||||
},
|
||||
{
|
||||
name: params.memberName,
|
||||
role: 'Developer',
|
||||
providerId: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
agentId: params.agentId,
|
||||
backendType: 'tmux',
|
||||
},
|
||||
],
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
await fs.writeFile(
|
||||
path.join(teamDir, 'launch-state.json'),
|
||||
`${JSON.stringify(
|
||||
{
|
||||
version: 2,
|
||||
teamName: params.teamName,
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
leadSessionId: 'lead-session',
|
||||
launchPhase: 'active',
|
||||
expectedMembers: [params.memberName],
|
||||
members: {
|
||||
[params.memberName]: {
|
||||
name: params.memberName,
|
||||
providerId: 'anthropic',
|
||||
model: 'claude-sonnet-4-6',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
lastHeartbeatAt: '2026-04-24T12:00:00.000Z',
|
||||
lastEvaluatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
summary: {
|
||||
confirmedCount: 1,
|
||||
pendingCount: 0,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 0,
|
||||
},
|
||||
teamLaunchState: 'clean_success',
|
||||
},
|
||||
null,
|
||||
2
|
||||
)}\n`,
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
async function waitForProcessCommand(
|
||||
pid: number,
|
||||
agentId: string,
|
||||
teamName: string,
|
||||
getDebugTail: () => string = () => ''
|
||||
): Promise<void> {
|
||||
const deadline = Date.now() + 5_000;
|
||||
while (Date.now() < deadline) {
|
||||
const output = await readProcessCommand(pid).catch(() => '');
|
||||
if (output.includes(agentId) && output.includes(teamName)) {
|
||||
return;
|
||||
}
|
||||
await sleep(100);
|
||||
}
|
||||
const debugTail = getDebugTail().trim();
|
||||
throw new Error(
|
||||
`Process ${pid} did not appear in ps with expected team identity${
|
||||
debugTail ? `\nCLI stderr tail:\n${debugTail}` : ''
|
||||
}`
|
||||
);
|
||||
}
|
||||
|
||||
function readProcessCommand(pid: number): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const ps = spawn('ps', ['-p', String(pid), '-o', 'command='], {
|
||||
stdio: ['ignore', 'pipe', 'pipe'],
|
||||
});
|
||||
let stdout = '';
|
||||
let stderr = '';
|
||||
ps.stdout?.on('data', (chunk) => {
|
||||
stdout += String(chunk);
|
||||
});
|
||||
ps.stderr?.on('data', (chunk) => {
|
||||
stderr += String(chunk);
|
||||
});
|
||||
ps.on('error', reject);
|
||||
ps.on('close', (code) => {
|
||||
if (code === 0) {
|
||||
resolve(stdout.trim());
|
||||
} else {
|
||||
reject(new Error(stderr.trim() || `ps exited with ${code}`));
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function waitForExit(child: ChildProcess, timeoutMs: number): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
if (child.exitCode != null || child.signalCode != null) {
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const timeout = setTimeout(() => {
|
||||
child.off('exit', onExit);
|
||||
reject(new Error('Timed out waiting for process exit'));
|
||||
}, timeoutMs);
|
||||
const onExit = (): void => {
|
||||
clearTimeout(timeout);
|
||||
resolve();
|
||||
};
|
||||
child.once('exit', onExit);
|
||||
});
|
||||
}
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
|
@ -1,14 +1,6 @@
|
|||
// @vitest-environment node
|
||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
const getConfigMock = vi.fn();
|
||||
|
||||
vi.mock('@main/services/infrastructure/ConfigManager', () => ({
|
||||
configManager: {
|
||||
getConfig: () => getConfigMock(),
|
||||
},
|
||||
}));
|
||||
|
||||
describe('cliFlavor', () => {
|
||||
afterEach(() => {
|
||||
delete process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||
|
|
@ -16,37 +8,20 @@ describe('cliFlavor', () => {
|
|||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('uses multimodel runtime by default when config enables it', async () => {
|
||||
getConfigMock.mockReturnValue({
|
||||
general: {
|
||||
multimodelEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
it('uses multimodel runtime by default', async () => {
|
||||
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
|
||||
|
||||
expect(getConfiguredCliFlavor()).toBe('agent_teams_orchestrator');
|
||||
});
|
||||
|
||||
it('uses claude runtime when multimodel is disabled in config', async () => {
|
||||
getConfigMock.mockReturnValue({
|
||||
general: {
|
||||
multimodelEnabled: false,
|
||||
},
|
||||
});
|
||||
|
||||
it('ignores the legacy persisted multimodel flag', async () => {
|
||||
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
|
||||
|
||||
expect(getConfiguredCliFlavor()).toBe('claude');
|
||||
expect(getConfiguredCliFlavor()).toBe('agent_teams_orchestrator');
|
||||
});
|
||||
|
||||
it('lets env override the persisted config', async () => {
|
||||
it('lets env override the default runtime', async () => {
|
||||
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'claude';
|
||||
getConfigMock.mockReturnValue({
|
||||
general: {
|
||||
multimodelEnabled: true,
|
||||
},
|
||||
});
|
||||
|
||||
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
|
||||
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
|||
import {
|
||||
PROGRESS_LOG_TAIL_LINES,
|
||||
PROGRESS_OUTPUT_TAIL_PARTS,
|
||||
boundLaunchDiagnostics,
|
||||
buildProgressAssistantOutput,
|
||||
buildProgressLogsTail,
|
||||
} from '../../../../src/main/services/team/progressPayload';
|
||||
|
|
@ -75,3 +76,32 @@ describe('buildProgressAssistantOutput', () => {
|
|||
expect(result!.split('\n\n')).toHaveLength(PROGRESS_OUTPUT_TAIL_PARTS);
|
||||
});
|
||||
});
|
||||
|
||||
describe('boundLaunchDiagnostics', () => {
|
||||
it('redacts secret CLI flags and caps diagnostic payload size', () => {
|
||||
const longDetail = `node runtime --token super-secret ${'x'.repeat(800)}`;
|
||||
const result = boundLaunchDiagnostics([
|
||||
{
|
||||
id: 'bob:tmux_shell_only',
|
||||
memberName: 'bob',
|
||||
severity: 'warning',
|
||||
code: 'tmux_shell_only',
|
||||
label: 'bob - shell only --api-key abc123',
|
||||
detail: longDetail,
|
||||
observedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(result).toBeDefined();
|
||||
expect(result).toHaveLength(1);
|
||||
const first = result?.[0];
|
||||
expect(first).toBeDefined();
|
||||
if (!first) {
|
||||
throw new Error('Expected one bounded launch diagnostic');
|
||||
}
|
||||
expect(first.label).toContain('--api-key [redacted]');
|
||||
expect(first.detail).toContain('--token [redacted]');
|
||||
expect(first.detail).not.toContain('super-secret');
|
||||
expect(first.detail?.length).toBeLessThanOrEqual(500);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -52,7 +52,7 @@ describe('pathDecoder', () => {
|
|||
});
|
||||
|
||||
it('should encode a Windows-style absolute path', () => {
|
||||
expect(encodePath('C:\\Users\\username\\projectname')).toBe('-C:-Users-username-projectname');
|
||||
expect(encodePath('C:\\Users\\username\\projectname')).toBe('C--Users-username-projectname');
|
||||
});
|
||||
|
||||
it('should handle empty string', () => {
|
||||
|
|
@ -177,6 +177,10 @@ describe('pathDecoder', () => {
|
|||
});
|
||||
|
||||
it('should return true for valid Windows-style encoded path', () => {
|
||||
expect(isValidEncodedPath('C--Users-username-projectname')).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true for old colon Windows-style encoded path', () => {
|
||||
expect(isValidEncodedPath('-C:-Users-username-projectname')).toBe(true);
|
||||
});
|
||||
|
||||
|
|
|
|||
36
test/renderer/api/httpClient.teamRuntimeFallback.test.ts
Normal file
36
test/renderer/api/httpClient.teamRuntimeFallback.test.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { HttpAPIClient } from '../../../src/renderer/api/httpClient';
|
||||
|
||||
class MockEventSource {
|
||||
onopen: (() => void) | null = null;
|
||||
onerror: (() => void) | null = null;
|
||||
addEventListener(): void {
|
||||
// noop browser-mode stub
|
||||
}
|
||||
close(): void {
|
||||
// noop browser-mode stub
|
||||
}
|
||||
}
|
||||
|
||||
describe('HttpAPIClient team runtime browser fallback', () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
|
||||
it('returns valid member spawn and runtime snapshots when diagnostic fields are absent', async () => {
|
||||
vi.stubGlobal('EventSource', MockEventSource);
|
||||
const client = new HttpAPIClient('http://localhost:9999');
|
||||
|
||||
await expect(client.teams.getMemberSpawnStatuses('demo-team')).resolves.toEqual({
|
||||
statuses: {},
|
||||
runId: null,
|
||||
});
|
||||
await expect(client.teams.getTeamAgentRuntime('demo-team')).resolves.toMatchObject({
|
||||
teamName: 'demo-team',
|
||||
runId: null,
|
||||
members: {},
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -325,7 +325,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
window.localStorage.clear();
|
||||
});
|
||||
|
||||
it('keeps the Multimodel toggle visible and enabled on the dashboard while login is still required', async () => {
|
||||
it('shows multimodel status without exposing the legacy runtime toggle', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -340,8 +340,7 @@ describe('CLI status visibility during completed install state', () => {
|
|||
expect(host.textContent).toContain('Login');
|
||||
|
||||
const toggle = host.querySelector('[data-testid="multimodel-toggle"]');
|
||||
expect(toggle).not.toBeNull();
|
||||
expect(toggle?.hasAttribute('disabled')).toBe(false);
|
||||
expect(toggle).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -3,13 +3,8 @@ import { createRoot } from 'react-dom/client';
|
|||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) => React.createElement('button', { type: 'button', onClick }, children),
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) =>
|
||||
React.createElement('button', { type: 'button', onClick }, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
|
||||
|
|
@ -77,4 +72,117 @@ describe('ProvisioningProgressBlock', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('renders bounded launch diagnostics without opening CLI logs', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProvisioningProgressBlock, {
|
||||
title: 'Launching team',
|
||||
currentStepIndex: 2,
|
||||
loading: true,
|
||||
defaultLiveOutputOpen: false,
|
||||
cliLogsTail: 'tail line',
|
||||
launchDiagnostics: [
|
||||
{
|
||||
id: 'bob:tmux_shell_only',
|
||||
memberName: 'bob',
|
||||
severity: 'warning',
|
||||
code: 'tmux_shell_only',
|
||||
label: 'bob - shell only',
|
||||
detail: 'tmux pane foreground command is zsh',
|
||||
observedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'tom:runtime_not_found',
|
||||
memberName: 'tom',
|
||||
severity: 'warning',
|
||||
code: 'runtime_not_found',
|
||||
label: 'tom - no runtime found',
|
||||
detail: 'registered runtime metadata without live process',
|
||||
observedAt: '2026-04-24T12:00:01.000Z',
|
||||
},
|
||||
{
|
||||
id: 'jack:process_table_unavailable',
|
||||
memberName: 'jack',
|
||||
severity: 'warning',
|
||||
code: 'process_table_unavailable',
|
||||
label: 'jack - process table unavailable',
|
||||
detail: 'runtime pid could not be verified because process table is unavailable',
|
||||
observedAt: '2026-04-24T12:00:02.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('Diagnostics');
|
||||
expect(host.textContent).not.toContain('logs:tail line');
|
||||
|
||||
const button = Array.from(host.querySelectorAll('button')).find((candidate) =>
|
||||
candidate.textContent?.includes('Diagnostics')
|
||||
);
|
||||
expect(button).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
button?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('bob - shell only');
|
||||
expect(host.textContent).toContain('tmux pane foreground command is zsh');
|
||||
expect(host.textContent).toContain('tom - no runtime found');
|
||||
expect(host.textContent).toContain('registered runtime metadata without live process');
|
||||
expect(host.textContent).toContain('jack - process table unavailable');
|
||||
expect(host.textContent).toContain(
|
||||
'runtime pid could not be verified because process table is unavailable'
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('hides launch diagnostics when all entries are informational', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(ProvisioningProgressBlock, {
|
||||
title: 'Launching team',
|
||||
currentStepIndex: 2,
|
||||
loading: true,
|
||||
defaultLiveOutputOpen: false,
|
||||
launchDiagnostics: [
|
||||
{
|
||||
id: 'alice:bootstrap_confirmed',
|
||||
memberName: 'alice',
|
||||
severity: 'info',
|
||||
code: 'bootstrap_confirmed',
|
||||
label: 'alice - bootstrap confirmed',
|
||||
observedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
],
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).not.toContain('Diagnostics');
|
||||
expect(host.textContent).not.toContain('alice - bootstrap confirmed');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -228,7 +228,7 @@ describe('MemberCard starting-state visuals', () => {
|
|||
});
|
||||
|
||||
expect(host.textContent).not.toContain('online');
|
||||
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
|
||||
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -302,7 +302,7 @@ describe('MemberCard starting-state visuals', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows a connecting badge while runtime bootstrap is still pending after the process comes online', async () => {
|
||||
it('shows a waiting-for-bootstrap badge while runtime bootstrap is still pending after the process comes online', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -324,9 +324,9 @@ describe('MemberCard starting-state visuals', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('connecting');
|
||||
expect(host.textContent).toContain('waiting for bootstrap');
|
||||
expect(host.textContent).not.toContain('ready');
|
||||
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
|
||||
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -430,4 +430,156 @@ describe('MemberCard starting-state visuals', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('labels shared OpenCode host memory instead of member-owned runtime memory', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
runtimeSummary: 'minimax · via OpenCode · 183.9 MB',
|
||||
runtimeEntry: {
|
||||
memberName: 'alice',
|
||||
alive: true,
|
||||
restartable: false,
|
||||
providerId: 'opencode',
|
||||
pid: 333,
|
||||
pidSource: 'opencode_bridge',
|
||||
rssBytes: 183.9 * 1024 * 1024,
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[title="RSS source: shared OpenCode host"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('copies bounded launch diagnostics only for launch errors', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
runtimeRunId: 'run-42',
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'waiting',
|
||||
spawnLaunchState: 'runtime_pending_bootstrap',
|
||||
spawnRuntimeAlive: false,
|
||||
spawnEntry: {
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
agentToolAccepted: true,
|
||||
livenessKind: 'shell_only',
|
||||
runtimeDiagnostic: 'tmux pane foreground command is zsh',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
runtimeEntry: {
|
||||
memberName: 'alice',
|
||||
alive: false,
|
||||
restartable: true,
|
||||
pid: 26676,
|
||||
pidSource: 'tmux_pane',
|
||||
paneCurrentCommand: 'zsh',
|
||||
processCommand: 'node runtime --token super-secret',
|
||||
updatedAt: '2026-04-24T12:00:01.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[aria-label="Copy diagnostics"]')).toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberCard, {
|
||||
member,
|
||||
memberColor: 'blue',
|
||||
runtimeRunId: 'run-42',
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
spawnStatus: 'error',
|
||||
spawnLaunchState: 'failed_to_start',
|
||||
spawnRuntimeAlive: false,
|
||||
spawnError: 'spawn failed',
|
||||
spawnEntry: {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'spawn failed',
|
||||
agentToolAccepted: false,
|
||||
livenessKind: 'not_found',
|
||||
runtimeDiagnostic: 'spawn failed',
|
||||
runtimeDiagnosticSeverity: 'error',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
runtimeEntry: {
|
||||
memberName: 'alice',
|
||||
alive: false,
|
||||
restartable: true,
|
||||
pid: 26676,
|
||||
pidSource: 'tmux_pane',
|
||||
paneCurrentCommand: 'zsh',
|
||||
processCommand: 'node runtime --token super-secret',
|
||||
updatedAt: '2026-04-24T12:00:01.000Z',
|
||||
},
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const button = host.querySelector('[aria-label="Copy diagnostics"]') as HTMLButtonElement;
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
const payload = JSON.parse(writeText.mock.calls[0][0] as string) as {
|
||||
runId?: string;
|
||||
livenessKind?: string;
|
||||
processCommand?: string;
|
||||
};
|
||||
expect(payload.runId).toBe('run-42');
|
||||
expect(payload.livenessKind).toBe('not_found');
|
||||
expect(payload.processCommand).toContain('--token [redacted]');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -15,13 +15,7 @@ vi.mock('@renderer/hooks/useMemberStats', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/button', () => ({
|
||||
Button: ({
|
||||
children,
|
||||
onClick,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onClick?: () => void;
|
||||
}) =>
|
||||
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
|
|
@ -33,7 +27,8 @@ vi.mock('@renderer/components/ui/button', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||
Dialog: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
||||
Dialog: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
DialogContent: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
DialogFooter: ({ children }: { children: React.ReactNode }) =>
|
||||
|
|
@ -42,6 +37,15 @@ vi.mock('@renderer/components/ui/dialog', () => ({
|
|||
React.createElement('div', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/tabs', () => {
|
||||
let currentValue = '';
|
||||
let currentOnValueChange: ((value: string) => void) | null = null;
|
||||
|
|
@ -60,14 +64,9 @@ vi.mock('@renderer/components/ui/tabs', () => {
|
|||
currentOnValueChange = onValueChange ?? null;
|
||||
return React.createElement('div', { 'data-tabs-value': value }, children);
|
||||
},
|
||||
TabsList: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
||||
TabsTrigger: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) =>
|
||||
TabsList: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
TabsTrigger: ({ children, value }: { children: React.ReactNode; value: string }) =>
|
||||
React.createElement(
|
||||
'button',
|
||||
{
|
||||
|
|
@ -77,13 +76,8 @@ vi.mock('@renderer/components/ui/tabs', () => {
|
|||
},
|
||||
children
|
||||
),
|
||||
TabsContent: ({
|
||||
children,
|
||||
value,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
value: string;
|
||||
}) => (currentValue === value ? React.createElement('div', null, children) : null),
|
||||
TabsContent: ({ children, value }: { children: React.ReactNode; value: string }) =>
|
||||
currentValue === value ? React.createElement('div', null, children) : null,
|
||||
};
|
||||
});
|
||||
|
||||
|
|
@ -93,7 +87,11 @@ vi.mock('@renderer/components/team/members/MemberDetailHeader', () => ({
|
|||
|
||||
vi.mock('@renderer/components/team/members/MemberDetailStats', () => ({
|
||||
MemberDetailStats: ({ activityCount }: { activityCount: number }) =>
|
||||
React.createElement('div', { 'data-testid': 'member-detail-stats' }, `activity-count:${activityCount}`),
|
||||
React.createElement(
|
||||
'div',
|
||||
{ 'data-testid': 'member-detail-stats' },
|
||||
`activity-count:${activityCount}`
|
||||
),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/members/MemberTasksTab', () => ({
|
||||
|
|
@ -210,4 +208,132 @@ describe('MemberDetailDialog activity count', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('copies launch diagnostics from the detail footer only for launch errors', async () => {
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
const member: ResolvedTeamMember = {
|
||||
name: 'jack',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
};
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberDetailDialog, {
|
||||
open: true,
|
||||
member,
|
||||
teamName: 'demo-team',
|
||||
runtimeRunId: 'run-42',
|
||||
members: [member],
|
||||
tasks: [],
|
||||
spawnEntry: {
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: false,
|
||||
agentToolAccepted: true,
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
runtimeDiagnostic: 'runtime process candidate detected',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
runtimeEntry: {
|
||||
memberName: 'jack',
|
||||
alive: false,
|
||||
restartable: true,
|
||||
pid: 4242,
|
||||
pidSource: 'tmux_child',
|
||||
processCommand: 'node runtime --api-key abc123',
|
||||
updatedAt: '2026-04-24T12:00:01.000Z',
|
||||
},
|
||||
onClose: () => undefined,
|
||||
onSendMessage: () => undefined,
|
||||
onAssignTask: () => undefined,
|
||||
onTaskClick: () => undefined,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
let copyButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Copy diagnostics')
|
||||
);
|
||||
expect(copyButton).toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberDetailDialog, {
|
||||
open: true,
|
||||
member,
|
||||
teamName: 'demo-team',
|
||||
runtimeRunId: 'run-42',
|
||||
members: [member],
|
||||
tasks: [],
|
||||
spawnEntry: {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
runtimeAlive: false,
|
||||
bootstrapConfirmed: false,
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'runtime process failed',
|
||||
agentToolAccepted: false,
|
||||
livenessKind: 'not_found',
|
||||
runtimeDiagnostic: 'runtime process failed',
|
||||
runtimeDiagnosticSeverity: 'error',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
runtimeEntry: {
|
||||
memberName: 'jack',
|
||||
alive: false,
|
||||
restartable: true,
|
||||
pid: 4242,
|
||||
pidSource: 'tmux_child',
|
||||
processCommand: 'node runtime --api-key abc123',
|
||||
updatedAt: '2026-04-24T12:00:01.000Z',
|
||||
},
|
||||
onClose: () => undefined,
|
||||
onSendMessage: () => undefined,
|
||||
onAssignTask: () => undefined,
|
||||
onTaskClick: () => undefined,
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
copyButton = Array.from(host.querySelectorAll('button')).find((button) =>
|
||||
button.textContent?.includes('Copy diagnostics')
|
||||
);
|
||||
expect(copyButton).not.toBeUndefined();
|
||||
|
||||
await act(async () => {
|
||||
copyButton?.click();
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const payload = JSON.parse(writeText.mock.calls[0][0] as string) as {
|
||||
runId?: string;
|
||||
livenessKind?: string;
|
||||
processCommand?: string;
|
||||
};
|
||||
expect(payload.runId).toBe('run-42');
|
||||
expect(payload.livenessKind).toBe('not_found');
|
||||
expect(payload.processCommand).toContain('--api-key [redacted]');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -10,7 +10,8 @@ vi.mock('@renderer/components/ui/badge', () => ({
|
|||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
||||
DialogTitle: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
DialogDescription: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
}));
|
||||
|
|
@ -100,7 +101,7 @@ describe('MemberDetailHeader spawn-aware presence', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows connecting while the runtime is online but bootstrap is still pending', async () => {
|
||||
it('shows waiting for bootstrap while the runtime is online but bootstrap is still pending', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -121,9 +122,9 @@ describe('MemberDetailHeader spawn-aware presence', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('connecting');
|
||||
expect(host.textContent).toContain('waiting for bootstrap');
|
||||
expect(host.textContent).not.toContain('online');
|
||||
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
|
||||
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -45,6 +45,12 @@ const storeState = {
|
|||
updatedAt: string;
|
||||
runtimeAlive: boolean;
|
||||
livenessSource?: string;
|
||||
livenessKind?: string;
|
||||
runtimeDiagnostic?: string;
|
||||
runtimeDiagnosticSeverity?: string;
|
||||
error?: string;
|
||||
hardFailure?: boolean;
|
||||
hardFailureReason?: string;
|
||||
}
|
||||
>
|
||||
>,
|
||||
|
|
@ -52,6 +58,13 @@ const storeState = {
|
|||
'northstar-core': undefined,
|
||||
} as Record<string, unknown>,
|
||||
leadActivityByTeam: {},
|
||||
teamAgentRuntimeByTeam: {} as Record<
|
||||
string,
|
||||
{
|
||||
runId: string | null;
|
||||
members: Record<string, Record<string, unknown>>;
|
||||
}
|
||||
>,
|
||||
openMemberProfile: vi.fn(),
|
||||
};
|
||||
|
||||
|
|
@ -61,7 +74,11 @@ vi.mock('@renderer/store', () => ({
|
|||
|
||||
vi.mock('@renderer/store/slices/teamSlice', () => ({
|
||||
getCurrentProvisioningProgressForTeam: () => storeState.progress,
|
||||
selectResolvedMemberForTeamName: (state: typeof storeState, teamName: string, memberName: string) =>
|
||||
selectResolvedMemberForTeamName: (
|
||||
state: typeof storeState,
|
||||
teamName: string,
|
||||
memberName: string
|
||||
) =>
|
||||
(state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members.find(
|
||||
(candidate) => candidate.name === memberName
|
||||
) ?? null,
|
||||
|
|
@ -91,6 +108,15 @@ vi.mock('@renderer/components/ui/hover-card', () => ({
|
|||
React.createElement('div', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/ui/tooltip', () => ({
|
||||
Tooltip: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipTrigger: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement(React.Fragment, null, children),
|
||||
TooltipContent: ({ children }: { children: React.ReactNode }) =>
|
||||
React.createElement('div', null, children),
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
|
||||
CurrentTaskIndicator: () => null,
|
||||
}));
|
||||
|
|
@ -116,6 +142,7 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
|||
runtimeAlive: false,
|
||||
};
|
||||
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined;
|
||||
storeState.teamAgentRuntimeByTeam = {};
|
||||
storeState.openMemberProfile.mockReset();
|
||||
});
|
||||
|
||||
|
|
@ -144,7 +171,7 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows connecting for runtime-pending members while launch is still settling', async () => {
|
||||
it('shows starting for runtime-pending members while launch is still settling', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.progress = {
|
||||
runId: 'run-1',
|
||||
|
|
@ -188,9 +215,9 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('connecting');
|
||||
expect(host.textContent).toContain('starting');
|
||||
expect(host.textContent).not.toContain('online');
|
||||
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
|
||||
expect(host.querySelector('[aria-label="starting"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -198,7 +225,7 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('shows connecting while runtime is online but bootstrap is still pending outside launch settling', async () => {
|
||||
it('shows waiting for bootstrap while runtime is online but bootstrap is still pending outside launch settling', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.progress = null;
|
||||
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
|
||||
|
|
@ -224,9 +251,9 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('connecting');
|
||||
expect(host.textContent).toContain('waiting for bootstrap');
|
||||
expect(host.textContent).not.toContain('online');
|
||||
expect(host.querySelector('[aria-label="connecting"]')).not.toBeNull();
|
||||
expect(host.querySelector('[aria-label="waiting for bootstrap"]')).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
@ -279,4 +306,96 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
|
||||
it('copies launch diagnostics with the active runtime run id only for launch errors', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
const writeText = vi.fn().mockResolvedValue(undefined);
|
||||
Object.defineProperty(navigator, 'clipboard', {
|
||||
configurable: true,
|
||||
value: { writeText },
|
||||
});
|
||||
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
|
||||
status: 'waiting',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
updatedAt: '2026-04-09T10:00:00.000Z',
|
||||
runtimeAlive: false,
|
||||
livenessKind: 'shell_only',
|
||||
runtimeDiagnostic: 'tmux pane foreground command is zsh',
|
||||
runtimeDiagnosticSeverity: 'warning',
|
||||
};
|
||||
storeState.teamAgentRuntimeByTeam['northstar-core'] = {
|
||||
runId: 'runtime-run-1',
|
||||
members: {
|
||||
alice: {
|
||||
memberName: 'alice',
|
||||
alive: false,
|
||||
restartable: true,
|
||||
livenessKind: 'shell_only',
|
||||
pidSource: 'tmux_pane',
|
||||
paneCurrentCommand: 'zsh',
|
||||
processCommand: 'node runtime --token secret',
|
||||
updatedAt: '2026-04-09T10:00:01.000Z',
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberHoverCard, {
|
||||
name: 'alice',
|
||||
children: React.createElement('button', { type: 'button' }, 'alice'),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.querySelector('[aria-label="Copy diagnostics"]')).toBeNull();
|
||||
|
||||
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
updatedAt: '2026-04-09T10:00:00.000Z',
|
||||
runtimeAlive: false,
|
||||
livenessKind: 'not_found',
|
||||
runtimeDiagnostic: 'spawn failed',
|
||||
runtimeDiagnosticSeverity: 'error',
|
||||
error: 'spawn failed',
|
||||
hardFailure: true,
|
||||
hardFailureReason: 'spawn failed',
|
||||
};
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(MemberHoverCard, {
|
||||
name: 'alice',
|
||||
children: React.createElement('button', { type: 'button' }, 'alice'),
|
||||
})
|
||||
);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const button = host.querySelector('[aria-label="Copy diagnostics"]') as HTMLButtonElement;
|
||||
expect(button).not.toBeNull();
|
||||
|
||||
await act(async () => {
|
||||
button.click();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const payload = JSON.parse(writeText.mock.calls[0][0] as string) as {
|
||||
runId?: string;
|
||||
processCommand?: string;
|
||||
};
|
||||
expect(payload.runId).toBe('runtime-run-1');
|
||||
expect(payload.processCommand).toContain('--token [redacted]');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await Promise.resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
107
test/renderer/components/team/provisioningSteps.test.ts
Normal file
107
test/renderer/components/team/provisioningSteps.test.ts
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { getLaunchJoinMilestonesFromMembers } from '@renderer/components/team/provisioningSteps';
|
||||
|
||||
const members = [{ name: 'alice' }, { name: 'bob' }, { name: 'tom' }, { name: 'jane' }];
|
||||
|
||||
describe('getLaunchJoinMilestonesFromMembers', () => {
|
||||
it('does not count shell-only liveness as process alive', () => {
|
||||
const milestones = getLaunchJoinMilestonesFromMembers({
|
||||
members,
|
||||
memberSpawnStatuses: {
|
||||
alice: {
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'process',
|
||||
livenessKind: 'shell_only',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
bob: {
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'process',
|
||||
livenessKind: 'runtime_process',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
tom: {
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'process',
|
||||
livenessKind: 'runtime_process_candidate',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
jane: {
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'process',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(milestones.processOnlyAliveCount).toBe(1);
|
||||
expect(milestones.pendingSpawnCount).toBe(3);
|
||||
});
|
||||
|
||||
it('does not count missing liveness kind as process alive', () => {
|
||||
const milestones = getLaunchJoinMilestonesFromMembers({
|
||||
members,
|
||||
memberSpawnStatuses: {
|
||||
alice: {
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
runtimeAlive: true,
|
||||
livenessSource: 'process',
|
||||
updatedAt: '2026-04-24T12:00:00.000Z',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(milestones.processOnlyAliveCount).toBe(0);
|
||||
expect(milestones.pendingSpawnCount).toBe(4);
|
||||
});
|
||||
|
||||
it('uses runtimeProcessPendingCount instead of legacy runtimeAlivePendingCount for snapshot pending math', () => {
|
||||
const milestones = getLaunchJoinMilestonesFromMembers({
|
||||
members,
|
||||
memberSpawnSnapshot: {
|
||||
expectedMembers: ['alice', 'bob', 'tom', 'jane'],
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 4,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 3,
|
||||
runtimeProcessPendingCount: 1,
|
||||
shellOnlyPendingCount: 1,
|
||||
runtimeCandidatePendingCount: 1,
|
||||
permissionPendingCount: 1,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(milestones.processOnlyAliveCount).toBe(1);
|
||||
expect(milestones.pendingSpawnCount).toBe(3);
|
||||
});
|
||||
|
||||
it('does not trust legacy runtimeAlivePendingCount without runtime process count', () => {
|
||||
const milestones = getLaunchJoinMilestonesFromMembers({
|
||||
members,
|
||||
memberSpawnSnapshot: {
|
||||
expectedMembers: ['alice', 'bob', 'tom', 'jane'],
|
||||
summary: {
|
||||
confirmedCount: 0,
|
||||
pendingCount: 4,
|
||||
failedCount: 0,
|
||||
runtimeAlivePendingCount: 3,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
expect(milestones.processOnlyAliveCount).toBe(0);
|
||||
expect(milestones.pendingSpawnCount).toBe(4);
|
||||
});
|
||||
});
|
||||
|
|
@ -60,13 +60,16 @@ function makeOverflowNode(): GraphNode {
|
|||
}
|
||||
|
||||
describe('GraphNodePopover spawn badge labels', () => {
|
||||
afterEach(() => {
|
||||
afterEach(async () => {
|
||||
await act(async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: null,
|
||||
selectedTeamData: null,
|
||||
teamDataCacheByName: {},
|
||||
} as never);
|
||||
await Promise.resolve();
|
||||
});
|
||||
document.body.innerHTML = '';
|
||||
useStore.setState({
|
||||
selectedTeamName: null,
|
||||
selectedTeamData: null,
|
||||
teamDataCacheByName: {},
|
||||
} as never);
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
|
|
@ -138,45 +141,48 @@ describe('GraphNodePopover spawn badge labels', () => {
|
|||
|
||||
it('reuses launch-aware presence semantics from cached team data', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
useStore.setState({
|
||||
teamDataCacheByName: {
|
||||
'northstar-core': {
|
||||
teamName: 'northstar-core',
|
||||
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
agentType: 'reviewer',
|
||||
providerId: 'codex',
|
||||
},
|
||||
],
|
||||
tasks: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'northstar-core', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
isAlive: true,
|
||||
},
|
||||
},
|
||||
memberSpawnStatusesByTeam: {
|
||||
'northstar-core': {
|
||||
alice: {
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
livenessSource: 'process',
|
||||
runtimeAlive: true,
|
||||
await act(async () => {
|
||||
useStore.setState({
|
||||
teamDataCacheByName: {
|
||||
'northstar-core': {
|
||||
teamName: 'northstar-core',
|
||||
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
||||
members: [
|
||||
{
|
||||
name: 'alice',
|
||||
status: 'active',
|
||||
currentTaskId: null,
|
||||
taskCount: 0,
|
||||
lastActiveAt: null,
|
||||
messageCount: 0,
|
||||
agentType: 'reviewer',
|
||||
providerId: 'codex',
|
||||
},
|
||||
],
|
||||
tasks: [],
|
||||
messages: [],
|
||||
kanbanState: { teamName: 'northstar-core', reviewers: [], tasks: {} },
|
||||
processes: [],
|
||||
isAlive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
memberSpawnSnapshotsByTeam: {},
|
||||
currentProvisioningRunIdByTeam: {},
|
||||
provisioningRuns: {},
|
||||
leadActivityByTeam: {},
|
||||
} as never);
|
||||
memberSpawnStatusesByTeam: {
|
||||
'northstar-core': {
|
||||
alice: {
|
||||
status: 'online',
|
||||
launchState: 'runtime_pending_bootstrap',
|
||||
livenessSource: 'process',
|
||||
runtimeAlive: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
memberSpawnSnapshotsByTeam: {},
|
||||
currentProvisioningRunIdByTeam: {},
|
||||
provisioningRuns: {},
|
||||
leadActivityByTeam: {},
|
||||
} as never);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
|
|
@ -193,7 +199,7 @@ describe('GraphNodePopover spawn badge labels', () => {
|
|||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('connecting');
|
||||
expect(host.textContent).toContain('waiting for bootstrap');
|
||||
expect(host.textContent).not.toContain('Idle');
|
||||
|
||||
await act(async () => {
|
||||
|
|
@ -204,48 +210,10 @@ describe('GraphNodePopover spawn badge labels', () => {
|
|||
|
||||
it('renders overflow stack contents instead of the task card and opens task detail from the list', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
useStore.setState({
|
||||
selectedTeamName: 'northstar-core',
|
||||
selectedTeamData: {
|
||||
teamName: 'northstar-core',
|
||||
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
displayId: '#1',
|
||||
subject: 'Tighten rollout checklist',
|
||||
owner: 'alice',
|
||||
reviewer: 'bob',
|
||||
status: 'in_progress',
|
||||
reviewState: 'review',
|
||||
kanbanColumn: 'review',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
displayId: '#2',
|
||||
subject: 'Patch release notes',
|
||||
owner: 'alice',
|
||||
status: 'pending',
|
||||
reviewState: 'none',
|
||||
},
|
||||
],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: {
|
||||
teamName: 'northstar-core',
|
||||
reviewers: [],
|
||||
tasks: {
|
||||
'task-1': {
|
||||
column: 'review',
|
||||
reviewer: 'bob',
|
||||
movedAt: '2026-04-12T18:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
processes: [],
|
||||
},
|
||||
teamDataCacheByName: {
|
||||
'northstar-core': {
|
||||
await act(async () => {
|
||||
useStore.setState({
|
||||
selectedTeamName: 'northstar-core',
|
||||
selectedTeamData: {
|
||||
teamName: 'northstar-core',
|
||||
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
||||
tasks: [
|
||||
|
|
@ -283,8 +251,49 @@ describe('GraphNodePopover spawn badge labels', () => {
|
|||
},
|
||||
processes: [],
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
teamDataCacheByName: {
|
||||
'northstar-core': {
|
||||
teamName: 'northstar-core',
|
||||
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
||||
tasks: [
|
||||
{
|
||||
id: 'task-1',
|
||||
displayId: '#1',
|
||||
subject: 'Tighten rollout checklist',
|
||||
owner: 'alice',
|
||||
reviewer: 'bob',
|
||||
status: 'in_progress',
|
||||
reviewState: 'review',
|
||||
kanbanColumn: 'review',
|
||||
},
|
||||
{
|
||||
id: 'task-2',
|
||||
displayId: '#2',
|
||||
subject: 'Patch release notes',
|
||||
owner: 'alice',
|
||||
status: 'pending',
|
||||
reviewState: 'none',
|
||||
},
|
||||
],
|
||||
members: [],
|
||||
messages: [],
|
||||
kanbanState: {
|
||||
teamName: 'northstar-core',
|
||||
reviewers: [],
|
||||
tasks: {
|
||||
'task-1': {
|
||||
column: 'review',
|
||||
reviewer: 'bob',
|
||||
movedAt: '2026-04-12T18:00:00.000Z',
|
||||
},
|
||||
},
|
||||
},
|
||||
processes: [],
|
||||
},
|
||||
},
|
||||
} as never);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const onOpenTaskDetail = vi.fn();
|
||||
const host = document.createElement('div');
|
||||
|
|
|
|||
|
|
@ -1136,8 +1136,8 @@ describe('TeamGraphAdapter particles', () => {
|
|||
);
|
||||
|
||||
expect(findNode(graph, 'member:my-team:alice')).toMatchObject({
|
||||
launchVisualState: 'runtime_pending',
|
||||
launchStatusLabel: 'connecting',
|
||||
launchVisualState: 'waiting',
|
||||
launchStatusLabel: 'waiting to start',
|
||||
});
|
||||
});
|
||||
|
||||
|
|
|
|||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Reference in a new issue