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) {
|
function resolveTargetLead(paths, config) {
|
||||||
// 1. config.members — agentType check
|
// 1. config.members - canonical lead detection shared with queue routing
|
||||||
if (config && config.members && config.members.length) {
|
if (config && config.members && config.members.length) {
|
||||||
const lead = config.members.find((m) => m && m.agentType === 'team-lead');
|
const lead = config.members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
|
||||||
if (lead && lead.name) return String(lead.name).trim();
|
if (lead && lead.name) return String(lead.name).trim();
|
||||||
|
|
||||||
// 2. config.members — name check
|
|
||||||
const namedLead = config.members.find((m) => m && m.name === 'team-lead');
|
|
||||||
if (namedLead && namedLead.name) return String(namedLead.name).trim();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. members.meta.json — WITH normalization (trim + dedup)
|
// 2. members.meta.json - WITH normalization (trim + dedup)
|
||||||
const metaPath = path.join(paths.teamDir, 'members.meta.json');
|
const metaPath = path.join(paths.teamDir, 'members.meta.json');
|
||||||
try {
|
try {
|
||||||
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
const raw = JSON.parse(fs.readFileSync(metaPath, 'utf8'));
|
||||||
const members = normalizeMetaMembers(raw && raw.members);
|
const members = normalizeMetaMembers(raw && raw.members);
|
||||||
if (members.length > 0) {
|
if (members.length > 0) {
|
||||||
const metaLead = members.find(
|
const metaLead = members.find((m) => runtimeHelpers.isCanonicalLeadMember(m));
|
||||||
(m) => m.agentType === 'team-lead' || m.name === 'team-lead'
|
|
||||||
);
|
|
||||||
if (metaLead && metaLead.name) return metaLead.name;
|
if (metaLead && metaLead.name) return metaLead.name;
|
||||||
return members[0].name;
|
return members[0].name;
|
||||||
}
|
}
|
||||||
|
|
@ -74,13 +68,8 @@ function resolveTargetLead(paths, config) {
|
||||||
/* ENOENT or parse error */
|
/* ENOENT or parse error */
|
||||||
}
|
}
|
||||||
|
|
||||||
// 4. role-based (legacy compat)
|
// 3. First configured member
|
||||||
if (config && config.members && config.members.length) {
|
if (config && config.members && config.members.length) {
|
||||||
const roleLead = config.members.find(
|
|
||||||
(m) => m && m.role && String(m.role).toLowerCase().includes('lead')
|
|
||||||
);
|
|
||||||
if (roleLead && roleLead.name) return String(roleLead.name).trim();
|
|
||||||
// 5. First member
|
|
||||||
if (config.members[0] && config.members[0].name) return String(config.members[0].name).trim();
|
if (config.members[0] && config.members[0].name) return String(config.members[0].name).trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -141,7 +130,7 @@ function findRecentDuplicate(outboxList, dedupeKey) {
|
||||||
function sendCrossTeamMessage(context, flags) {
|
function sendCrossTeamMessage(context, flags) {
|
||||||
const fromTeam = context.teamName;
|
const fromTeam = context.teamName;
|
||||||
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
|
const toTeam = typeof flags.toTeam === 'string' ? flags.toTeam.trim() : '';
|
||||||
const fromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : 'team-lead';
|
const rawFromMember = typeof flags.fromMember === 'string' ? flags.fromMember.trim() : '';
|
||||||
const replyToConversationId =
|
const replyToConversationId =
|
||||||
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
|
typeof flags.replyToConversationId === 'string' ? flags.replyToConversationId.trim() : '';
|
||||||
const conversationId =
|
const conversationId =
|
||||||
|
|
@ -156,6 +145,10 @@ function sendCrossTeamMessage(context, flags) {
|
||||||
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
|
if (!TEAM_NAME_PATTERN.test(fromTeam)) {
|
||||||
throw new Error(`Invalid fromTeam: ${fromTeam}`);
|
throw new Error(`Invalid fromTeam: ${fromTeam}`);
|
||||||
}
|
}
|
||||||
|
const sourceConfig = runtimeHelpers.readTeamConfig(context.paths);
|
||||||
|
if (!sourceConfig || sourceConfig.deletedAt) {
|
||||||
|
throw new Error(`Source team not found: ${fromTeam}`);
|
||||||
|
}
|
||||||
if (!TEAM_NAME_PATTERN.test(toTeam)) {
|
if (!TEAM_NAME_PATTERN.test(toTeam)) {
|
||||||
throw new Error(`Invalid toTeam: ${toTeam}`);
|
throw new Error(`Invalid toTeam: ${toTeam}`);
|
||||||
}
|
}
|
||||||
|
|
@ -165,6 +158,11 @@ function sendCrossTeamMessage(context, flags) {
|
||||||
if (!text || text.trim().length === 0) {
|
if (!text || text.trim().length === 0) {
|
||||||
throw new Error('Message text is required');
|
throw new Error('Message text is required');
|
||||||
}
|
}
|
||||||
|
const fromMember = rawFromMember
|
||||||
|
? runtimeHelpers.assertExplicitTeamMemberName(context.paths, rawFromMember, 'cross-team sender', {
|
||||||
|
allowLeadAliases: true,
|
||||||
|
})
|
||||||
|
: runtimeHelpers.inferLeadName(context.paths);
|
||||||
|
|
||||||
// Target context + config
|
// Target context + config
|
||||||
const targetContext = createTargetContext(context, toTeam);
|
const targetContext = createTargetContext(context, toTeam);
|
||||||
|
|
|
||||||
|
|
@ -101,12 +101,12 @@ function listReviewers(context) {
|
||||||
|
|
||||||
function addReviewer(context, reviewer) {
|
function addReviewer(context, reviewer) {
|
||||||
return withTeamBoardLock(context.paths, () => {
|
return withTeamBoardLock(context.paths, () => {
|
||||||
runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
|
const resolvedReviewer = runtimeHelpers.assertExplicitTeamMemberName(context.paths, reviewer, 'reviewer', {
|
||||||
allowLeadAliases: true,
|
allowLeadAliases: true,
|
||||||
});
|
});
|
||||||
const state = getKanbanState(context);
|
const state = getKanbanState(context);
|
||||||
const next = new Set(state.reviewers);
|
const next = new Set(state.reviewers);
|
||||||
next.add(String(reviewer));
|
next.add(String(resolvedReviewer));
|
||||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||||
...state,
|
...state,
|
||||||
reviewers: [...next],
|
reviewers: [...next],
|
||||||
|
|
@ -118,7 +118,13 @@ function addReviewer(context, reviewer) {
|
||||||
function removeReviewer(context, reviewer) {
|
function removeReviewer(context, reviewer) {
|
||||||
return withTeamBoardLock(context.paths, () => {
|
return withTeamBoardLock(context.paths, () => {
|
||||||
const state = getKanbanState(context);
|
const state = getKanbanState(context);
|
||||||
const next = state.reviewers.filter((entry) => entry !== reviewer);
|
const resolvedReviewer = runtimeHelpers.resolveExplicitTeamMemberName(context.paths, reviewer, {
|
||||||
|
allowLeadAliases: true,
|
||||||
|
});
|
||||||
|
const reviewerNames = new Set(
|
||||||
|
[reviewer, resolvedReviewer].filter((entry) => typeof entry === 'string' && entry.trim())
|
||||||
|
);
|
||||||
|
const next = state.reviewers.filter((entry) => !reviewerNames.has(entry));
|
||||||
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
kanbanStore.writeKanbanState(context.paths, context.teamName, {
|
||||||
...state,
|
...state,
|
||||||
reviewers: next,
|
reviewers: next,
|
||||||
|
|
|
||||||
|
|
@ -69,10 +69,9 @@ function normalizeActorKey(value) {
|
||||||
function resolveKnownActorName(context, value, label) {
|
function resolveKnownActorName(context, value, label) {
|
||||||
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||||
if (!actor) return null;
|
if (!actor) return null;
|
||||||
runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
|
return runtimeHelpers.assertExplicitTeamMemberName(context.paths, actor, label, {
|
||||||
allowLeadAliases: true,
|
allowLeadAliases: true,
|
||||||
});
|
});
|
||||||
return actor;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function tryResolveKnownActorName(context, value, label) {
|
function tryResolveKnownActorName(context, value, label) {
|
||||||
|
|
@ -301,9 +300,10 @@ function requestReview(context, taskId, flags = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const nextFrom =
|
const nextFrom =
|
||||||
resolveKnownActorName(context, flags.from, 'review requester') || 'team-lead';
|
resolveKnownActorName(context, flags.from, 'review requester') ||
|
||||||
|
resolveKnownActorName(context, 'team-lead', 'review requester');
|
||||||
const rawReviewer = getReviewer(context, flags);
|
const rawReviewer = getReviewer(context, flags);
|
||||||
const nextReviewer = rawReviewer ? (resolveKnownActorName(context, rawReviewer, 'reviewer'), rawReviewer) : null;
|
const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null;
|
||||||
const prevReviewState = getEffectiveReviewState(context, currentTask);
|
const prevReviewState = getEffectiveReviewState(context, currentTask);
|
||||||
if (prevReviewState === 'approved') {
|
if (prevReviewState === 'approved') {
|
||||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);
|
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);
|
||||||
|
|
|
||||||
|
|
@ -80,19 +80,46 @@ function getEffectiveReviewState(task, kanbanEntry) {
|
||||||
return historyState;
|
return historyState;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const 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);
|
const persisted = normalizeReviewState(task && task.reviewState);
|
||||||
if (persisted !== 'none') {
|
const persistedFallback = normalizeFallback(persisted, 'task_review_state');
|
||||||
return {
|
if (persistedFallback) {
|
||||||
state: persisted,
|
return persistedFallback;
|
||||||
source: 'task_review_state',
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
|
if (kanbanEntry && REVIEW_COLUMNS.has(kanbanEntry.column)) {
|
||||||
return {
|
const kanbanFallback = normalizeFallback(kanbanEntry.column, 'kanban_column');
|
||||||
state: normalizeReviewState(kanbanEntry.column),
|
if (kanbanFallback) {
|
||||||
source: 'kanban_column',
|
return kanbanFallback;
|
||||||
};
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@ const crypto = require('crypto');
|
||||||
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
const TASK_ATTACHMENTS_DIR = 'task-attachments';
|
||||||
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
const MAX_TASK_ATTACHMENT_BYTES = 20 * 1024 * 1024;
|
||||||
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
const TEAM_NAME_PATTERN = /^[a-z0-9][a-z0-9-]{0,127}$/;
|
||||||
|
const LEAD_AGENT_TYPES = new Set(['team-lead', 'lead', 'orchestrator']);
|
||||||
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
const CROSS_TEAM_TOOL_RECIPIENT_NAMES = new Set([
|
||||||
'cross_team_send',
|
'cross_team_send',
|
||||||
'cross_team_list_targets',
|
'cross_team_list_targets',
|
||||||
|
|
@ -130,7 +131,7 @@ function isCanonicalLeadMember(member) {
|
||||||
const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : '';
|
const role = typeof member.role === 'string' ? member.role.trim().toLowerCase() : '';
|
||||||
const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : '';
|
const name = typeof member.name === 'string' ? member.name.trim().toLowerCase() : '';
|
||||||
return (
|
return (
|
||||||
agentType === 'team-lead' ||
|
LEAD_AGENT_TYPES.has(agentType) ||
|
||||||
name === 'team-lead' ||
|
name === 'team-lead' ||
|
||||||
role === 'team-lead' ||
|
role === 'team-lead' ||
|
||||||
role === 'team lead' ||
|
role === 'team lead' ||
|
||||||
|
|
@ -175,12 +176,10 @@ function inferLeadName(paths) {
|
||||||
const resolved = resolveTeamMembers(paths);
|
const resolved = resolveTeamMembers(paths);
|
||||||
const members = resolved.members || [];
|
const members = resolved.members || [];
|
||||||
const lead =
|
const lead =
|
||||||
members.find(
|
members.find((member) => {
|
||||||
(member) =>
|
const agentType = typeof member?.agentType === 'string' ? member.agentType.trim().toLowerCase() : '';
|
||||||
member &&
|
return LEAD_AGENT_TYPES.has(agentType);
|
||||||
typeof member.agentType === 'string' &&
|
}) ||
|
||||||
member.agentType.trim().toLowerCase() === 'team-lead'
|
|
||||||
) ||
|
|
||||||
members.find((member) => String((member && member.name) || '').trim().toLowerCase() === 'team-lead') ||
|
members.find((member) => String((member && member.name) || '').trim().toLowerCase() === 'team-lead') ||
|
||||||
members.find(
|
members.find(
|
||||||
(member) => {
|
(member) => {
|
||||||
|
|
|
||||||
|
|
@ -187,19 +187,23 @@ function maybeNotifyTaskOwnerOnComment(context, task, comment, options = {}) {
|
||||||
}
|
}
|
||||||
|
|
||||||
function createTask(context, input) {
|
function createTask(context, input) {
|
||||||
|
let taskInput = input;
|
||||||
if (input && typeof input.owner === 'string' && input.owner.trim()) {
|
if (input && typeof input.owner === 'string' && input.owner.trim()) {
|
||||||
assertKnownTaskActor(context, input.owner, 'task owner');
|
taskInput = {
|
||||||
|
...input,
|
||||||
|
owner: assertKnownTaskActor(context, input.owner, 'task owner'),
|
||||||
|
};
|
||||||
}
|
}
|
||||||
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, input));
|
const task = withTeamBoardLock(context.paths, () => taskStore.createTask(context.paths, taskInput));
|
||||||
if (input && input.notifyOwner !== false) {
|
if (taskInput && taskInput.notifyOwner !== false) {
|
||||||
maybeNotifyAssignedOwner(context, task, {
|
maybeNotifyAssignedOwner(context, task, {
|
||||||
description: input.description,
|
description: taskInput.description,
|
||||||
prompt: input.prompt,
|
prompt: taskInput.prompt,
|
||||||
taskRefs: [
|
taskRefs: [
|
||||||
...(Array.isArray(input.descriptionTaskRefs) ? input.descriptionTaskRefs : []),
|
...(Array.isArray(taskInput.descriptionTaskRefs) ? taskInput.descriptionTaskRefs : []),
|
||||||
...(Array.isArray(input.promptTaskRefs) ? input.promptTaskRefs : []),
|
...(Array.isArray(taskInput.promptTaskRefs) ? taskInput.promptTaskRefs : []),
|
||||||
],
|
],
|
||||||
from: input.from,
|
from: taskInput.from,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return task;
|
return task;
|
||||||
|
|
@ -382,6 +386,10 @@ function softDeleteTask(context, taskId, actor) {
|
||||||
|
|
||||||
function restoreTask(context, taskId, actor) {
|
function restoreTask(context, taskId, actor) {
|
||||||
return withTeamBoardLock(context.paths, () => {
|
return withTeamBoardLock(context.paths, () => {
|
||||||
|
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||||
|
if (before.status !== 'deleted') {
|
||||||
|
throw new Error(`Task #${before.displayId || before.id} is not deleted; task_restore only restores deleted tasks`);
|
||||||
|
}
|
||||||
let task = taskStore.setTaskStatus(context.paths, taskId, 'pending', actor || 'user');
|
let task = taskStore.setTaskStatus(context.paths, taskId, 'pending', actor || 'user');
|
||||||
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
const state = kanbanStore.readKanbanState(context.paths, context.teamName);
|
||||||
if (hasKanbanReference(state, task.id)) {
|
if (hasKanbanReference(state, task.id)) {
|
||||||
|
|
@ -403,7 +411,7 @@ function setTaskOwner(context, taskId, owner) {
|
||||||
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
const before = taskStore.readTask(context.paths, taskId, { includeDeleted: true });
|
||||||
const nextOwner = isClearOwnerValue(owner)
|
const nextOwner = isClearOwnerValue(owner)
|
||||||
? owner
|
? owner
|
||||||
: (assertKnownTaskActor(context, owner, 'task owner'), owner);
|
: assertKnownTaskActor(context, owner, 'task owner');
|
||||||
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
|
const after = taskStore.setTaskOwner(context.paths, taskId, nextOwner);
|
||||||
return {
|
return {
|
||||||
previousTask: before,
|
previousTask: before,
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,7 @@ const AGENT_TEAMS_TASK_TOOL_NAMES = [
|
||||||
'task_get_comment',
|
'task_get_comment',
|
||||||
'task_link',
|
'task_link',
|
||||||
'task_list',
|
'task_list',
|
||||||
|
'task_restore',
|
||||||
'task_set_clarification',
|
'task_set_clarification',
|
||||||
'task_set_owner',
|
'task_set_owner',
|
||||||
'task_set_status',
|
'task_set_status',
|
||||||
|
|
|
||||||
|
|
@ -385,6 +385,31 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(briefing).toContain('Counters: actionable=4, awareness=3');
|
expect(briefing).toContain('Counters: actionable=4, awareness=3');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('treats stale legacy terminal reviewState on pending tasks as owner-ready work', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
||||||
|
const staleTask = controller.tasks.createTask({
|
||||||
|
subject: 'Legacy stale approved task',
|
||||||
|
owner: 'bob',
|
||||||
|
status: 'pending',
|
||||||
|
reviewState: 'approved',
|
||||||
|
notifyOwner: false,
|
||||||
|
});
|
||||||
|
|
||||||
|
const briefing = await controller.tasks.taskBriefing('bob');
|
||||||
|
const staleLine = briefing.split('\n').find((line) => line.includes(`#${staleTask.displayId}`));
|
||||||
|
expect(staleLine).toContain('[status=pending]');
|
||||||
|
expect(staleLine).not.toContain('review=');
|
||||||
|
expect(staleLine).toContain('reason=owner_ready');
|
||||||
|
|
||||||
|
const rows = controller.tasks.listTaskInventory({ owner: 'bob' });
|
||||||
|
expect(rows.find((row) => row.id === staleTask.id)).toMatchObject({
|
||||||
|
status: 'pending',
|
||||||
|
reviewState: 'none',
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
it('reconciles stale kanban rows and linked inbox comments idempotently', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
@ -887,7 +912,7 @@ describe('agent-teams-controller API', () => {
|
||||||
text: 'Need your decision here.',
|
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'));
|
const rows = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||||
expect(rows).toHaveLength(1);
|
expect(rows).toHaveLength(1);
|
||||||
expect(rows[0].from).toBe('bob');
|
expect(rows[0].from).toBe('bob');
|
||||||
|
|
@ -1114,6 +1139,82 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(leadBriefing).not.toContain(`#${task.displayId}`);
|
expect(leadBriefing).not.toContain(`#${task.displayId}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('recognizes lead and orchestrator agent types as canonical team leads', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: 'my-team',
|
||||||
|
leadSessionId: 'lead-session-1',
|
||||||
|
members: [
|
||||||
|
{ name: 'alice', role: 'developer' },
|
||||||
|
{ name: 'leadbot', agentType: 'lead' },
|
||||||
|
{ name: 'opsbot', agentType: 'orchestrator' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const aliceTask = controller.tasks.createTask({ subject: 'Alice owns this', owner: 'alice' });
|
||||||
|
const leadTask = controller.tasks.createTask({ subject: 'Lead owns this', owner: 'leadbot' });
|
||||||
|
const aliceBriefing = await controller.tasks.taskBriefing('alice');
|
||||||
|
const leadBriefing = await controller.tasks.leadBriefing();
|
||||||
|
|
||||||
|
expect(aliceBriefing).toContain(`#${aliceTask.displayId}`);
|
||||||
|
expect(aliceBriefing).toContain('actionOwner=@alice');
|
||||||
|
expect(aliceBriefing).not.toContain(`#${leadTask.displayId}`);
|
||||||
|
expect(leadBriefing).toContain(`#${leadTask.displayId}`);
|
||||||
|
expect(leadBriefing).not.toContain(`#${aliceTask.displayId}`);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('stores canonical member names for lead aliases in owners, reviewers, and reviewer config', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
configPath,
|
||||||
|
JSON.stringify(
|
||||||
|
{
|
||||||
|
name: 'my-team',
|
||||||
|
members: [
|
||||||
|
{ name: 'leadbot', agentType: 'lead' },
|
||||||
|
{ name: 'alice', role: 'reviewer' },
|
||||||
|
{ name: 'bob', role: 'developer' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
null,
|
||||||
|
2
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' });
|
||||||
|
expect(leadOwnedTask.owner).toBe('leadbot');
|
||||||
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||||
|
|
||||||
|
const reassignedTask = controller.tasks.createTask({ subject: 'Reassign alias owner', owner: 'bob' });
|
||||||
|
expect(controller.tasks.setTaskOwner(reassignedTask.id, 'team-lead').owner).toBe('leadbot');
|
||||||
|
|
||||||
|
controller.kanban.addReviewer('lead');
|
||||||
|
expect(controller.kanban.listReviewers()).toEqual(['leadbot']);
|
||||||
|
|
||||||
|
const reviewTask = controller.tasks.createTask({ subject: 'Review alias', owner: 'bob' });
|
||||||
|
controller.tasks.completeTask(reviewTask.id, 'bob');
|
||||||
|
controller.review.requestReview(reviewTask.id, { from: 'alice', reviewer: 'lead' });
|
||||||
|
|
||||||
|
const requested = controller.tasks
|
||||||
|
.getTask(reviewTask.id)
|
||||||
|
.historyEvents.filter((event) => event.type === 'review_requested')
|
||||||
|
.at(-1);
|
||||||
|
expect(requested.reviewer).toBe('leadbot');
|
||||||
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'leadbot.json'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'lead.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('rejects task_briefing for unknown members', async () => {
|
it('rejects task_briefing for unknown members', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
@ -1305,6 +1406,22 @@ describe('agent-teams-controller API', () => {
|
||||||
expect(restored.reviewState).toBe('none');
|
expect(restored.reviewState).toBe('none');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('rejects task_restore for non-deleted tasks', () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
const task = controller.tasks.createTask({ subject: 'Approved task must stay approved', owner: 'bob' });
|
||||||
|
|
||||||
|
controller.tasks.completeTask(task.id, 'bob');
|
||||||
|
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||||
|
controller.review.approveReview(task.id, { from: 'alice' });
|
||||||
|
|
||||||
|
expect(() => controller.tasks.restoreTask(task.id, 'alice')).toThrow(
|
||||||
|
'task_restore only restores deleted tasks'
|
||||||
|
);
|
||||||
|
expect(controller.tasks.getTask(task.id).status).toBe('completed');
|
||||||
|
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
|
||||||
|
});
|
||||||
|
|
||||||
it('uses actual kanban overlay for kanbanColumn inventory filters', () => {
|
it('uses actual kanban overlay for kanbanColumn inventory filters', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
|
||||||
|
|
@ -59,8 +59,8 @@ describe('crossTeam module', () => {
|
||||||
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
const inbox = JSON.parse(fs.readFileSync(inboxPath, 'utf8'));
|
||||||
expect(inbox).toHaveLength(1);
|
expect(inbox).toHaveLength(1);
|
||||||
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
|
expect(inbox[0].source).toBe(CROSS_TEAM_SOURCE);
|
||||||
expect(inbox[0].from).toBe('team-a.lead');
|
expect(inbox[0].from).toBe('team-a.team-lead');
|
||||||
expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.lead" depth="0"`);
|
expect(inbox[0].text).toContain(`<${CROSS_TEAM_TAG_NAME} from="team-a.team-lead" depth="0"`);
|
||||||
expect(inbox[0].conversationId).toBeTruthy();
|
expect(inbox[0].conversationId).toBeTruthy();
|
||||||
expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`);
|
expect(inbox[0].text).toContain(`conversationId="${inbox[0].conversationId}"`);
|
||||||
});
|
});
|
||||||
|
|
@ -314,6 +314,108 @@ describe('crossTeam module', () => {
|
||||||
expect(fs.existsSync(inboxPath)).toBe(true);
|
expect(fs.existsSync(inboxPath)).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('resolves supported lead agent types before tech-lead role text', () => {
|
||||||
|
const claudeDir = makeClaudeDir({
|
||||||
|
'team-a': {
|
||||||
|
name: 'team-a',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
},
|
||||||
|
'team-b': {
|
||||||
|
name: 'team-b',
|
||||||
|
members: [
|
||||||
|
{ name: 'alice', role: 'tech lead' },
|
||||||
|
{ name: 'olivia', agentType: 'lead' },
|
||||||
|
],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||||
|
controller.crossTeam.sendCrossTeamMessage({
|
||||||
|
toTeam: 'team-b',
|
||||||
|
text: 'Hello',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'olivia.json'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('resolves orchestrator lead from members.meta.json', () => {
|
||||||
|
const claudeDir = makeClaudeDir({
|
||||||
|
'team-a': {
|
||||||
|
name: 'team-a',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
},
|
||||||
|
'team-b': {
|
||||||
|
name: 'team-b',
|
||||||
|
members: [],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const metaPath = path.join(claudeDir, 'teams', 'team-b', 'members.meta.json');
|
||||||
|
fs.writeFileSync(
|
||||||
|
metaPath,
|
||||||
|
JSON.stringify({
|
||||||
|
members: [
|
||||||
|
{ name: 'alice', role: 'tech lead' },
|
||||||
|
{ name: 'orla', agentType: 'orchestrator' },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||||
|
controller.crossTeam.sendCrossTeamMessage({
|
||||||
|
toTeam: 'team-b',
|
||||||
|
text: 'Hello',
|
||||||
|
});
|
||||||
|
|
||||||
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'orla.json'))).toBe(true);
|
||||||
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'alice.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects phantom source teams before delivery or outbox writes', () => {
|
||||||
|
const claudeDir = makeClaudeDir({
|
||||||
|
'team-b': {
|
||||||
|
name: 'team-b',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
controller.crossTeam.sendCrossTeamMessage({
|
||||||
|
toTeam: 'team-b',
|
||||||
|
text: 'Hello from nowhere',
|
||||||
|
})
|
||||||
|
).toThrow('Source team not found: team-a');
|
||||||
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-a'))).toBe(false);
|
||||||
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('rejects unknown cross-team senders', () => {
|
||||||
|
const claudeDir = makeClaudeDir({
|
||||||
|
'team-a': {
|
||||||
|
name: 'team-a',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
},
|
||||||
|
'team-b': {
|
||||||
|
name: 'team-b',
|
||||||
|
members: [{ name: 'team-lead', agentType: 'team-lead' }],
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const controller = createController({ teamName: 'team-a', claudeDir });
|
||||||
|
|
||||||
|
expect(() =>
|
||||||
|
controller.crossTeam.sendCrossTeamMessage({
|
||||||
|
toTeam: 'team-b',
|
||||||
|
fromMember: 'alicce',
|
||||||
|
text: 'Hello',
|
||||||
|
})
|
||||||
|
).toThrow('Unknown cross-team sender: alicce');
|
||||||
|
expect(fs.existsSync(path.join(claudeDir, 'teams', 'team-b', 'inboxes', 'team-lead.json'))).toBe(false);
|
||||||
|
});
|
||||||
|
|
||||||
it('resolves lead by name fallback', () => {
|
it('resolves lead by name fallback', () => {
|
||||||
const claudeDir = makeClaudeDir({
|
const claudeDir = makeClaudeDir({
|
||||||
'team-a': {
|
'team-a': {
|
||||||
|
|
|
||||||
|
|
@ -32,23 +32,23 @@
|
||||||
- `mcp-server/src/tools/runtimeTools.ts` уже содержит `runtime_bootstrap_checkin` и `runtime_heartbeat`. Это сильный сигнал, его надо сделать главным источником подтверждения.
|
- `mcp-server/src/tools/runtimeTools.ts` уже содержит `runtime_bootstrap_checkin` и `runtime_heartbeat`. Это сильный сигнал, его надо сделать главным источником подтверждения.
|
||||||
- `agent-teams-controller/src/internal/runtime.js` уже прокидывает `runtimeBootstrapCheckin()` в desktop runtime.
|
- `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`.
|
- `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.
|
- `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` выглядит логично.
|
- Для tmux раньше читался только `#{pane_id}\t#{pane_pid}` через `listTmuxPanePidsForCurrentPlatform()`. `pane_pid` часто является shell (`zsh`, `bash`, `sh`), поэтому `2 MB` выглядело логично.
|
||||||
- `attachLiveRuntimeMetadataToStatuses()` превращает `metadata.alive` в `runtimeAlive: true` и `livenessSource: "process"`.
|
- `attachLiveRuntimeMetadataToStatuses()` теперь повышает member до `runtimeAlive: true` только через strong evidence: `confirmed_bootstrap` или `runtime_process`.
|
||||||
- `reevaluateMemberLaunchStatus()` не fail-ит member после grace timeout, если `runtimeAlive === true`.
|
- `reevaluateMemberLaunchStatus()` больше не доверяет старому `runtimeAlive === true` без live metadata.
|
||||||
- `OpenCodeTeamRuntimeAdapter.mapBridgeMemberToRuntimeEvidence()` сейчас может выставить `runtimeAlive: true`, если bridge просто вернул member в состоянии `created` или `permission_blocked`. Это полезный материализационный сигнал, но он слабее реального `runtimePid` и слабее bootstrap.
|
- `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 уже есть, надо не дать слабым сигналам выглядеть как он.
|
- `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, который надо сохранить.
|
- `OpenCodeLaunchTransactionStore.canMarkOpenCodeRunReady()` уже требует `member_session_recorded`, `required_tools_proven` и `bootstrap_confirmed`. Это strict readiness precedent, который надо сохранить.
|
||||||
- Renderer уже получает оба источника: `memberSpawnStatuses` и `teamAgentRuntimeByTeam`. Но `MemberCard` сейчас получает только `runtimeSummary` строкой, а не сам `TeamAgentRuntimeEntry`.
|
- Renderer уже получает оба источника: `memberSpawnStatuses` и `teamAgentRuntimeByTeam`. Но `MemberCard` сейчас получает только `runtimeSummary` строкой, а не сам `TeamAgentRuntimeEntry`.
|
||||||
- `teamSlice.areTeamAgentRuntimeEntriesEqual()` сейчас сравнивает только `memberName`, `alive`, `restartable`, `backendType`, `pid`, `runtimeModel`, `rssBytes`. Если добавить `livenessKind`, `pidSource`, `diagnostics`, но не обновить comparator, UI может не перерендериться.
|
- `teamSlice.areTeamAgentRuntimeEntriesEqual()` должен сравнивать `livenessKind`, `pidSource` и diagnostic fields, иначе UI может не перерендериться при смене strict evidence.
|
||||||
- `teamSlice.areMemberSpawnStatusEntriesEqual()` сейчас намеренно игнорирует timing fields и сравнивает только visible spawn fields. Если добавить `livenessKind/runtimeDiagnostic`, comparator тоже надо обновить.
|
- `teamSlice.areMemberSpawnStatusEntriesEqual()` должен сравнивать visible liveness fields (`livenessKind/runtimeDiagnostic`) и продолжать игнорировать timing-only fields.
|
||||||
- `areLaunchSummaryCountsEqual()` сейчас знает только `confirmedCount`, `pendingCount`, `failedCount`, `runtimeAlivePendingCount`. Новые aggregate diagnostic counts не будут обновлять UI без расширения comparator.
|
- `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.
|
- `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.
|
- 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`, эта информация теряется.
|
- 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-семантику.
|
- `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.
|
- `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.
|
- `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.
|
- `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
|
## Rollout mode
|
||||||
|
|
||||||
Строгая модель меняет поведение launch timeout, поэтому ее надо включать контролируемо.
|
Строгая модель меняет поведение launch timeout, поэтому изначальный план рассматривал rollout через отдельный флаг.
|
||||||
|
Текущая реализация после hardening включает strict liveness по умолчанию и не содержит старый переключатель режима.
|
||||||
|
|
||||||
Топ 3 rollout вариантов:
|
Актуальное поведение:
|
||||||
|
|
||||||
1. Diagnostics-only default, strict behind env flag
|
| Area | Strict-only behavior |
|
||||||
🎯 9 🛡️ 9 🧠 5 Примерно 80-140 строк.
|
| ------------------------------ | ---------------------------------------- |
|
||||||
По умолчанию UI получает новые diagnostics, но `runtimeAlive` behavior остается старым. Strict включается через env для dogfood. Это самый безопасный путь для первого PR.
|
| `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 сразу
|
Operational rollback должен быть отдельным code revert или follow-up setting, а не скрытым env-флагом.
|
||||||
🎯 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.
|
|
||||||
|
|
||||||
## Structured launch diagnostics
|
## Structured launch diagnostics
|
||||||
|
|
||||||
|
|
@ -1251,7 +1214,7 @@ if (
|
||||||
|
|
||||||
Файл: `src/main/services/team/TeamProvisioningService.ts`
|
Файл: `src/main/services/team/TeamProvisioningService.ts`
|
||||||
|
|
||||||
`handleMemberSpawnToolResult()` сейчас содержит shortcut:
|
`handleMemberSpawnToolResult()` раньше содержал shortcut:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
if (parsedStatus.reason === 'already_running') {
|
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 процессом.
|
В strict liveness модели это опасно: `already_running` доказывает, что runtime/CLI отказался дублировать spawn, но не доказывает, что нужный teammate сейчас прошел bootstrap или что текущий pane PID является runtime процессом.
|
||||||
|
|
||||||
Новая логика:
|
Итоговая логика:
|
||||||
|
|
||||||
```ts
|
```ts
|
||||||
if (parsedStatus.reason === 'already_running') {
|
if (parsedStatus.reason === 'already_running') {
|
||||||
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
||||||
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
||||||
const runtime = await this.findStrongRuntimeEvidenceForMember(run.teamName, spawnedMemberName);
|
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
||||||
if (isStrongRuntimeEvidence(runtime)) {
|
this.appendMemberBootstrapDiagnostic(
|
||||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
|
run,
|
||||||
} else {
|
spawnedMemberName,
|
||||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
'already_running requires strong runtime verification'
|
||||||
this.setMemberRuntimeDiagnostic(run, spawnedMemberName, {
|
);
|
||||||
livenessKind: runtime?.livenessKind ?? 'registered_only',
|
void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
|
||||||
message: 'Runtime reported already running, but no verified member process was found yet.',
|
|
||||||
severity: 'warning',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -1825,6 +1784,7 @@ export interface PersistedTeamLaunchSummary {
|
||||||
confirmedCount: number;
|
confirmedCount: number;
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
|
// Compatibility aggregate only. Do not use as process evidence in UI.
|
||||||
runtimeAlivePendingCount: number;
|
runtimeAlivePendingCount: number;
|
||||||
shellOnlyPendingCount?: number;
|
shellOnlyPendingCount?: number;
|
||||||
runtimeProcessPendingCount?: number;
|
runtimeProcessPendingCount?: number;
|
||||||
|
|
@ -1905,11 +1865,6 @@ Backend/shared:
|
||||||
- добавить компактные diagnostic fields в `MemberSpawnStatusEntry`.
|
- добавить компактные diagnostic fields в `MemberSpawnStatusEntry`.
|
||||||
- добавить bounded `TeamLaunchDiagnosticItem` и `TeamProvisioningProgress.launchDiagnostics`.
|
- добавить 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`
|
- `src/main/services/team/TeamRuntimeLivenessResolver.ts`
|
||||||
- вынести pure liveness classification;
|
- вынести pure liveness classification;
|
||||||
- принимать tmux/process/OpenCode/persisted facts;
|
- принимать tmux/process/OpenCode/persisted facts;
|
||||||
|
|
@ -1928,7 +1883,6 @@ Backend/shared:
|
||||||
- расширить `LiveTeamAgentRuntimeMetadata`;
|
- расширить `LiveTeamAgentRuntimeMetadata`;
|
||||||
- parse sanitized runtime tool `metadata`;
|
- parse sanitized runtime tool `metadata`;
|
||||||
- добавить strict evidence helpers;
|
- добавить strict evidence helpers;
|
||||||
- подключить `TeamMemberLivenessMode`;
|
|
||||||
- использовать `TeamRuntimeLivenessResolver`;
|
- использовать `TeamRuntimeLivenessResolver`;
|
||||||
- обновить `updateProgress()` extras для `launchDiagnostics`;
|
- обновить `updateProgress()` extras для `launchDiagnostics`;
|
||||||
- переписать tmux/process resolution;
|
- переписать tmux/process resolution;
|
||||||
|
|
@ -2005,11 +1959,6 @@ Renderer:
|
||||||
|
|
||||||
Backend:
|
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`
|
- `TeamRuntimeLivenessResolver.test.ts`
|
||||||
- tmux foreground shell + no child -> `shell_only`;
|
- tmux foreground shell + no child -> `shell_only`;
|
||||||
- verified process row by `--team-name` + `--agent-id` -> `runtime_process`;
|
- verified process row by `--team-name` + `--agent-id` -> `runtime_process`;
|
||||||
|
|
@ -2088,9 +2037,9 @@ Renderer:
|
||||||
|
|
||||||
Add:
|
Add:
|
||||||
|
|
||||||
- `TeamMemberLivenessMode` with default `diagnostics`;
|
|
||||||
- `TeamRuntimeLivenessResolver` pure tests;
|
- `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:
|
Verification:
|
||||||
|
|
||||||
|
|
@ -2103,14 +2052,11 @@ pnpm exec vitest run test/main/features/tmux-installer test/main/services/team/T
|
||||||
|
|
||||||
🎯 9 🛡️ 9 🧠 7 Примерно 220-320 строк.
|
🎯 9 🛡️ 9 🧠 7 Примерно 220-320 строк.
|
||||||
|
|
||||||
Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only when `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict`. Shell/pane/candidate больше не выставляют `runtimeAlive` в strict mode.
|
Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence only. Shell/pane/candidate больше не выставляют `runtimeAlive`.
|
||||||
|
|
||||||
Keep diagnostics mode as rollback until manual launch scenarios pass.
|
|
||||||
|
|
||||||
Verification:
|
Verification:
|
||||||
|
|
||||||
```bash
|
```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
|
pnpm exec vitest run test/main/services/team/TeamProvisioningService.test.ts
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|
@ -2204,9 +2150,9 @@ Scenarios:
|
||||||
- `member-spawn` event refreshes runtime snapshot.
|
- `member-spawn` event refreshes runtime snapshot.
|
||||||
|
|
||||||
9. Rollout безопасен:
|
9. Rollout безопасен:
|
||||||
- default `diagnostics` mode не меняет hard timeout behavior до включения strict;
|
- strict behavior включен по умолчанию;
|
||||||
- `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=strict` включает strong-only behavior;
|
- diagnostics UI остается доступным без отдельного mode flag;
|
||||||
- `CLAUDE_TEAM_MEMBER_LIVENESS_MODE=diagnostics` работает как rollback без удаления UI diagnostics.
|
- rollback требует явного code revert или отдельного follow-up setting.
|
||||||
|
|
||||||
10. Provider failures не создают ложный ready:
|
10. Provider failures не создают ложный ready:
|
||||||
|
|
||||||
|
|
@ -2282,22 +2228,21 @@ Mitigation:
|
||||||
## Minimal safe patch order
|
## Minimal safe patch order
|
||||||
|
|
||||||
1. Добавить типы и optional fields.
|
1. Добавить типы и optional fields.
|
||||||
2. Добавить `TeamMemberLivenessMode` default `diagnostics`.
|
2. Добавить sanitized runtime tool metadata parser.
|
||||||
3. Добавить sanitized runtime tool metadata parser.
|
3. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`.
|
||||||
4. Добавить tmux `listPaneRuntimeInfo()` и сохранить wrapper `listPanePids()`.
|
4. Добавить process table provider/parser с `ppid`.
|
||||||
5. Добавить process table provider/parser с `ppid`.
|
5. Вынести `TeamRuntimeLivenessResolver`.
|
||||||
6. Вынести `TeamRuntimeLivenessResolver`.
|
6. Заполнить `livenessKind`.
|
||||||
7. Заполнить `livenessKind` без behavior change.
|
7. Написать backend tests на shell-only, verified runtime, stale event, metadata PID.
|
||||||
8. Написать backend tests на shell-only, verified runtime, stale event, metadata PID.
|
8. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence.
|
||||||
9. Переключить `attachLiveRuntimeMetadataToStatuses()` на strong evidence behind strict mode.
|
9. Исправить `already_running` shortcut.
|
||||||
10. Исправить `already_running` shortcut behind strict mode.
|
10. Переключить timeout/self-heal logic на strong evidence.
|
||||||
11. Переключить timeout/self-heal logic behind strict mode.
|
11. Исправить OpenCode bridge mapping.
|
||||||
12. Исправить OpenCode bridge mapping.
|
12. Обновить persisted summary diagnostics и store equality.
|
||||||
13. Обновить persisted summary diagnostics и store equality.
|
13. Добавить `launchDiagnostics` в progress payload и UI disclosure.
|
||||||
14. Добавить `launchDiagnostics` в progress payload и UI disclosure.
|
14. Добавить renderer labels/tooltips/banner.
|
||||||
15. Добавить renderer labels/tooltips/banner.
|
15. Добавить copy diagnostics.
|
||||||
16. Добавить copy diagnostics.
|
16. Manual validation: создать команду, проверить pending names, runtime diagnostics и отсутствие false-ready shell-only процесса.
|
||||||
17. После manual validation включить strict default или оставить env rollback на один release.
|
|
||||||
|
|
||||||
## Expected UX
|
## Expected UX
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
import { jsonTextContent } from '../utils/format';
|
import { jsonTextContent } from '../utils/format';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -34,8 +35,9 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
conversationId,
|
conversationId,
|
||||||
replyToConversationId,
|
replyToConversationId,
|
||||||
chainDepth,
|
chainDepth,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
getController(teamName, claudeDir).crossTeam.sendCrossTeamMessage({
|
||||||
toTeam,
|
toTeam,
|
||||||
|
|
@ -47,7 +49,8 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(chainDepth !== undefined ? { chainDepth } : {}),
|
...(chainDepth !== undefined ? { chainDepth } : {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -57,14 +60,16 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
excludeTeam: z.string().optional(),
|
excludeTeam: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, excludeTeam }) =>
|
execute: async ({ teamName, claudeDir, excludeTeam }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
|
getController(teamName, claudeDir).crossTeam.listCrossTeamTargets({
|
||||||
...(excludeTeam ? { excludeTeam } : {}),
|
...(excludeTeam ? { excludeTeam } : {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -73,9 +78,11 @@ export function registerCrossTeamTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) =>
|
execute: async ({ teamName, claudeDir }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
|
jsonTextContent(getController(teamName, claudeDir).crossTeam.getCrossTeamOutbox())
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
import { jsonTextContent } from '../utils/format';
|
import { jsonTextContent } from '../utils/format';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -16,8 +17,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) =>
|
execute: async ({ teamName, claudeDir }) => {
|
||||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState())),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.getKanbanState()));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -29,10 +32,12 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
column: z.enum(['review', 'approved']),
|
column: z.enum(['review', 'approved']),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, column }) =>
|
execute: async ({ teamName, claudeDir, taskId, column }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
|
jsonTextContent(getController(teamName, claudeDir).kanban.setKanbanColumn(taskId, column))
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -43,8 +48,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
execute: async ({ teamName, claudeDir, taskId }) => {
|
||||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId))),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.clearKanban(taskId)));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -53,8 +60,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) =>
|
execute: async ({ teamName, claudeDir }) => {
|
||||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers())),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.listReviewers()));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -64,8 +73,10 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
reviewer: z.string().min(1),
|
reviewer: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
execute: async ({ teamName, claudeDir, reviewer }) => {
|
||||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer))),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).kanban.addReviewer(reviewer)));
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -75,9 +86,11 @@ export function registerKanbanTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
reviewer: z.string().min(1),
|
reviewer: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, reviewer }) =>
|
execute: async ({ teamName, claudeDir, reviewer }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
|
jsonTextContent(getController(teamName, claudeDir).kanban.removeReviewer(reviewer))
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import type { FastMCP } from 'fastmcp';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -20,13 +21,16 @@ export function registerLeadTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) => ({
|
execute: async ({ teamName, claudeDir }) => {
|
||||||
content: [
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
{
|
return {
|
||||||
type: 'text' as const,
|
content: [
|
||||||
text: await getController(teamName, claudeDir).tasks.leadBriefing(),
|
{
|
||||||
},
|
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 { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
import { jsonTextContent } from '../utils/format';
|
import { jsonTextContent } from '../utils/format';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
|
|
@ -42,8 +43,9 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
source,
|
source,
|
||||||
leadSessionId,
|
leadSessionId,
|
||||||
attachments,
|
attachments,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).messages.sendMessage({
|
getController(teamName, claudeDir).messages.sendMessage({
|
||||||
to,
|
to,
|
||||||
|
|
@ -55,6 +57,7 @@ export function registerMessageTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(attachments?.length ? { attachments } : {}),
|
...(attachments?.length ? { attachments } : {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
import { jsonTextContent } from '../utils/format';
|
import { jsonTextContent } from '../utils/format';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -34,8 +35,9 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
port,
|
port,
|
||||||
url,
|
url,
|
||||||
claudeProcessId,
|
claudeProcessId,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).processes.registerProcess({
|
getController(teamName, claudeDir).processes.registerProcess({
|
||||||
pid,
|
pid,
|
||||||
|
|
@ -47,7 +49,8 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
|
...(claudeProcessId ? { 'claude-process-id': claudeProcessId } : {}),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -57,10 +60,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
parameters: z.object({
|
parameters: z.object({
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir }) =>
|
execute: async ({ teamName, claudeDir }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
|
jsonTextContent(getController(teamName, claudeDir).processes.listProcesses())
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -71,10 +76,12 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
pid: z.number().int().positive(),
|
pid: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, pid }) =>
|
execute: async ({ teamName, claudeDir, pid }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
|
jsonTextContent(getController(teamName, claudeDir).processes.unregisterProcess({ pid }))
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -85,9 +92,11 @@ export function registerProcessTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
pid: z.number().int().positive(),
|
pid: z.number().int().positive(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, pid }) =>
|
execute: async ({ teamName, claudeDir, pid }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
|
jsonTextContent(getController(teamName, claudeDir).processes.stopProcess({ pid }))
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
import { jsonTextContent, slimTask } from '../utils/format';
|
import { jsonTextContent, slimTask } from '../utils/format';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -20,8 +21,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
reviewer: z.string().optional(),
|
reviewer: z.string().optional(),
|
||||||
leadSessionId: z.string().optional(),
|
leadSessionId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) =>
|
execute: async ({ teamName, claudeDir, taskId, from, reviewer, leadSessionId }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
slimTask(
|
slimTask(
|
||||||
getController(teamName, claudeDir).review.requestReview(taskId, {
|
getController(teamName, claudeDir).review.requestReview(taskId, {
|
||||||
|
|
@ -31,7 +33,8 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -42,14 +45,16 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
from: z.string().optional(),
|
from: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, from }) =>
|
execute: async ({ teamName, claudeDir, taskId, from }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).review.startReview(taskId, {
|
getController(teamName, claudeDir).review.startReview(taskId, {
|
||||||
...(from ? { from } : {}),
|
...(from ? { from } : {}),
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -63,8 +68,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
notifyOwner: z.boolean().optional(),
|
notifyOwner: z.boolean().optional(),
|
||||||
leadSessionId: z.string().optional(),
|
leadSessionId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) =>
|
execute: async ({ teamName, claudeDir, taskId, from, note, notifyOwner, leadSessionId }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
slimTask(
|
slimTask(
|
||||||
getController(teamName, claudeDir).review.approveReview(taskId, {
|
getController(teamName, claudeDir).review.approveReview(taskId, {
|
||||||
|
|
@ -75,7 +81,8 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -88,8 +95,9 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
comment: z.string().optional(),
|
comment: z.string().optional(),
|
||||||
leadSessionId: z.string().optional(),
|
leadSessionId: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) =>
|
execute: async ({ teamName, claudeDir, taskId, from, comment, leadSessionId }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
slimTask(
|
slimTask(
|
||||||
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
getController(teamName, claudeDir).review.requestChanges(taskId, {
|
||||||
|
|
@ -99,6 +107,7 @@ export function registerReviewTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { z } from 'zod';
|
||||||
|
|
||||||
import { getController } from '../controller';
|
import { getController } from '../controller';
|
||||||
import { jsonTextContent } from '../utils/format';
|
import { jsonTextContent } from '../utils/format';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
|
|
||||||
const toolContextSchema = {
|
const toolContextSchema = {
|
||||||
teamName: z.string().min(1),
|
teamName: z.string().min(1),
|
||||||
|
|
@ -57,8 +58,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
worktree,
|
worktree,
|
||||||
extraCliArgs,
|
extraCliArgs,
|
||||||
waitForReady,
|
waitForReady,
|
||||||
}) =>
|
}) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.launchTeam({
|
await getController(teamName, claudeDir).runtime.launchTeam({
|
||||||
cwd,
|
cwd,
|
||||||
...(prompt ? { prompt } : {}),
|
...(prompt ? { prompt } : {}),
|
||||||
|
|
@ -72,7 +74,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
...(waitForReady !== undefined ? { waitForReady } : {}),
|
...(waitForReady !== undefined ? { waitForReady } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -82,14 +85,16 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
waitForStop: z.boolean().optional(),
|
waitForStop: z.boolean().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) =>
|
execute: async ({ teamName, claudeDir, controlUrl, waitTimeoutMs, waitForStop }) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.stopTeam({
|
await getController(teamName, claudeDir).runtime.stopTeam({
|
||||||
...(controlUrl ? { controlUrl } : {}),
|
...(controlUrl ? { controlUrl } : {}),
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
...(waitForStop !== undefined ? { waitForStop } : {}),
|
...(waitForStop !== undefined ? { waitForStop } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -112,8 +117,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
observedAt,
|
observedAt,
|
||||||
diagnostics,
|
diagnostics,
|
||||||
metadata,
|
metadata,
|
||||||
}) =>
|
}) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({
|
await getController(teamName, claudeDir).runtime.runtimeBootstrapCheckin({
|
||||||
runId,
|
runId,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -124,7 +130,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(controlUrl ? { controlUrl } : {}),
|
...(controlUrl ? { controlUrl } : {}),
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -156,8 +163,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
createdAt,
|
createdAt,
|
||||||
summary,
|
summary,
|
||||||
taskRefs,
|
taskRefs,
|
||||||
}) =>
|
}) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({
|
await getController(teamName, claudeDir).runtime.runtimeDeliverMessage({
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
runId,
|
runId,
|
||||||
|
|
@ -171,7 +179,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(controlUrl ? { controlUrl } : {}),
|
...(controlUrl ? { controlUrl } : {}),
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -203,8 +212,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
createdAt,
|
createdAt,
|
||||||
summary,
|
summary,
|
||||||
metadata,
|
metadata,
|
||||||
}) =>
|
}) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.runtimeTaskEvent({
|
await getController(teamName, claudeDir).runtime.runtimeTaskEvent({
|
||||||
idempotencyKey,
|
idempotencyKey,
|
||||||
runId,
|
runId,
|
||||||
|
|
@ -218,7 +228,8 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(controlUrl ? { controlUrl } : {}),
|
...(controlUrl ? { controlUrl } : {}),
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -241,8 +252,9 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
observedAt,
|
observedAt,
|
||||||
status,
|
status,
|
||||||
metadata,
|
metadata,
|
||||||
}) =>
|
}) => {
|
||||||
jsonTextContent(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return jsonTextContent(
|
||||||
await getController(teamName, claudeDir).runtime.runtimeHeartbeat({
|
await getController(teamName, claudeDir).runtime.runtimeHeartbeat({
|
||||||
runId,
|
runId,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -253,6 +265,7 @@ export function registerRuntimeTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...(controlUrl ? { controlUrl } : {}),
|
...(controlUrl ? { controlUrl } : {}),
|
||||||
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
...(waitTimeoutMs ? { waitTimeoutMs } : {}),
|
||||||
})
|
})
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,8 @@
|
||||||
import type { FastMCP } from 'fastmcp';
|
import type { FastMCP } from 'fastmcp';
|
||||||
import fs from 'node:fs';
|
|
||||||
import path from 'node:path';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { agentBlocks, getController } from '../controller';
|
import { agentBlocks, getController } from '../controller';
|
||||||
|
import { assertConfiguredTeam } from '../utils/teamConfig';
|
||||||
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
|
import { jsonTextContent, taskWriteResult, slimTask } from '../utils/format';
|
||||||
|
|
||||||
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */
|
/** stripAgentBlocks from canonical agentBlocks module — single source of truth for the tag format. */
|
||||||
|
|
@ -70,42 +69,6 @@ function buildCreateTaskPayload(params: {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function resolveConfigPath(teamName: string, claudeDir?: string): string {
|
|
||||||
const controller = getController(teamName, claudeDir) as {
|
|
||||||
context?: { paths?: { teamDir?: string } };
|
|
||||||
};
|
|
||||||
const teamDir = controller.context?.paths?.teamDir;
|
|
||||||
if (typeof teamDir !== 'string' || teamDir.trim().length === 0) {
|
|
||||||
throw new Error(
|
|
||||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return path.join(teamDir, 'config.json');
|
|
||||||
}
|
|
||||||
|
|
||||||
function assertConfiguredTeam(teamName: string, claudeDir?: string): void {
|
|
||||||
const configPath = resolveConfigPath(teamName, claudeDir);
|
|
||||||
let raw = '';
|
|
||||||
try {
|
|
||||||
raw = fs.readFileSync(configPath, 'utf8');
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
|
||||||
const parsed = JSON.parse(raw) as { name?: unknown };
|
|
||||||
if (typeof parsed?.name !== 'string' || parsed.name.trim().length === 0) {
|
|
||||||
throw new Error('invalid');
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
throw new Error(
|
|
||||||
`Unknown team "${teamName}". Board tools require an existing configured team with config.json. Use the real board teamName from durable team context - never use a member or lead name as teamName.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
server.addTool({
|
server.addTool({
|
||||||
name: 'task_create',
|
name: 'task_create',
|
||||||
|
|
@ -288,8 +251,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId }) =>
|
execute: async ({ teamName, claudeDir, taskId }) => {
|
||||||
await Promise.resolve(jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
|
jsonTextContent(getController(teamName, claudeDir).tasks.getTask(taskId))
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -301,12 +268,12 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
commentId: z.string().min(1),
|
commentId: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, commentId }) =>
|
execute: async ({ teamName, claudeDir, taskId, commentId }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
jsonTextContent(
|
return await Promise.resolve(
|
||||||
getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId)
|
jsonTextContent(getController(teamName, claudeDir).tasks.getTaskComment(taskId, commentId))
|
||||||
)
|
);
|
||||||
),
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -333,8 +300,9 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
relatedTo,
|
relatedTo,
|
||||||
blockedBy,
|
blockedBy,
|
||||||
limit,
|
limit,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
getController(teamName, claudeDir).tasks.listTaskInventory({
|
getController(teamName, claudeDir).tasks.listTaskInventory({
|
||||||
...(owner ? { owner } : {}),
|
...(owner ? { owner } : {}),
|
||||||
|
|
@ -346,7 +314,8 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
limit: normalizeTaskListLimit(limit),
|
limit: normalizeTaskListLimit(limit),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -358,10 +327,42 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
|
status: z.enum(['pending', 'in_progress', 'completed', 'deleted']),
|
||||||
actor: z.string().optional(),
|
actor: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, status, actor }) =>
|
execute: async ({ teamName, claudeDir, taskId, status, actor }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<string, unknown>))
|
return await Promise.resolve(
|
||||||
),
|
jsonTextContent(
|
||||||
|
slimTask(
|
||||||
|
getController(teamName, claudeDir).tasks.setTaskStatus(taskId, status, actor) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
server.addTool({
|
||||||
|
name: 'task_restore',
|
||||||
|
description: 'Restore a deleted task back to pending work state',
|
||||||
|
parameters: z.object({
|
||||||
|
...toolContextSchema,
|
||||||
|
taskId: z.string().min(1),
|
||||||
|
actor: z.string().optional(),
|
||||||
|
}),
|
||||||
|
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||||
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
|
jsonTextContent(
|
||||||
|
slimTask(
|
||||||
|
getController(teamName, claudeDir).tasks.restoreTask(taskId, actor) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -372,8 +373,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
actor: z.string().optional(),
|
actor: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||||
await Promise.resolve(jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<string, unknown>))),
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
|
jsonTextContent(
|
||||||
|
slimTask(
|
||||||
|
getController(teamName, claudeDir).tasks.startTask(taskId, actor) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -384,10 +396,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
actor: z.string().optional(),
|
actor: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, actor }) =>
|
execute: async ({ teamName, claudeDir, taskId, actor }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<string, unknown>))
|
return await Promise.resolve(
|
||||||
),
|
jsonTextContent(
|
||||||
|
slimTask(
|
||||||
|
getController(teamName, claudeDir).tasks.completeTask(taskId, actor) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -398,10 +419,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
owner: z.string().nullable(),
|
owner: z.string().nullable(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, owner }) =>
|
execute: async ({ teamName, claudeDir, taskId, owner }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<string, unknown>))
|
return await Promise.resolve(
|
||||||
),
|
jsonTextContent(
|
||||||
|
slimTask(
|
||||||
|
getController(teamName, claudeDir).tasks.setTaskOwner(taskId, owner) as Record<
|
||||||
|
string,
|
||||||
|
unknown
|
||||||
|
>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -413,17 +443,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
text: z.string().min(1),
|
text: z.string().min(1),
|
||||||
from: z.string().optional(),
|
from: z.string().optional(),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, text, from }) =>
|
execute: async ({ teamName, claudeDir, taskId, text, from }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
taskWriteResult(
|
taskWriteResult(
|
||||||
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
getController(teamName, claudeDir).tasks.addTaskComment(taskId, {
|
||||||
text,
|
text,
|
||||||
...(from ? { from } : {}),
|
...(from ? { from } : {}),
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -448,20 +480,22 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
filename,
|
filename,
|
||||||
mimeType,
|
mimeType,
|
||||||
noFallback,
|
noFallback,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
taskWriteResult(
|
taskWriteResult(
|
||||||
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
getController(teamName, claudeDir).tasks.attachTaskFile(taskId, {
|
||||||
file: filePath,
|
file: filePath,
|
||||||
...(mode ? { mode } : {}),
|
...(mode ? { mode } : {}),
|
||||||
...(filename ? { filename } : {}),
|
...(filename ? { filename } : {}),
|
||||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||||
...(noFallback ? { 'no-fallback': true } : {}),
|
...(noFallback ? { 'no-fallback': true } : {}),
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -488,20 +522,22 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
filename,
|
filename,
|
||||||
mimeType,
|
mimeType,
|
||||||
noFallback,
|
noFallback,
|
||||||
}) =>
|
}) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
taskWriteResult(
|
taskWriteResult(
|
||||||
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
getController(teamName, claudeDir).tasks.attachCommentFile(taskId, commentId, {
|
||||||
file: filePath,
|
file: filePath,
|
||||||
...(mode ? { mode } : {}),
|
...(mode ? { mode } : {}),
|
||||||
...(filename ? { filename } : {}),
|
...(filename ? { filename } : {}),
|
||||||
...(mimeType ? { 'mime-type': mimeType } : {}),
|
...(mimeType ? { 'mime-type': mimeType } : {}),
|
||||||
...(noFallback ? { 'no-fallback': true } : {}),
|
...(noFallback ? { 'no-fallback': true } : {}),
|
||||||
}) as Record<string, unknown>
|
}) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -512,17 +548,19 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
taskId: z.string().min(1),
|
taskId: z.string().min(1),
|
||||||
value: z.enum(['lead', 'user', 'clear']),
|
value: z.enum(['lead', 'user', 'clear']),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, value }) =>
|
execute: async ({ teamName, claudeDir, taskId, value }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
slimTask(
|
slimTask(
|
||||||
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
getController(teamName, claudeDir).tasks.setNeedsClarification(
|
||||||
taskId,
|
taskId,
|
||||||
value === 'clear' ? null : value
|
value === 'clear' ? null : value
|
||||||
) as Record<string, unknown>
|
) as Record<string, unknown>
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -534,10 +572,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
targetId: z.string().min(1),
|
targetId: z.string().min(1),
|
||||||
relationship: relationshipTypeSchema,
|
relationship: relationshipTypeSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
jsonTextContent(slimTask(getController(teamName, claudeDir).tasks.linkTask(taskId, targetId, relationship) as Record<string, unknown>))
|
return await Promise.resolve(
|
||||||
),
|
jsonTextContent(
|
||||||
|
slimTask(
|
||||||
|
getController(teamName, claudeDir).tasks.linkTask(
|
||||||
|
taskId,
|
||||||
|
targetId,
|
||||||
|
relationship
|
||||||
|
) as Record<string, unknown>
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -549,12 +597,20 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
targetId: z.string().min(1),
|
targetId: z.string().min(1),
|
||||||
relationship: relationshipTypeSchema,
|
relationship: relationshipTypeSchema,
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) =>
|
execute: async ({ teamName, claudeDir, taskId, targetId, relationship }) => {
|
||||||
await Promise.resolve(
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
|
return await Promise.resolve(
|
||||||
jsonTextContent(
|
jsonTextContent(
|
||||||
slimTask(getController(teamName, claudeDir).tasks.unlinkTask(taskId, targetId, relationship) as Record<string, unknown>)
|
slimTask(
|
||||||
|
getController(teamName, claudeDir).tasks.unlinkTask(
|
||||||
|
taskId,
|
||||||
|
targetId,
|
||||||
|
relationship
|
||||||
|
) as Record<string, unknown>
|
||||||
|
)
|
||||||
)
|
)
|
||||||
),
|
);
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -565,14 +621,17 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
memberName: z.string().min(1),
|
memberName: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, memberName }) => ({
|
execute: async ({ teamName, claudeDir, memberName }) => {
|
||||||
content: [
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
{
|
return {
|
||||||
type: 'text' as const,
|
content: [
|
||||||
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName),
|
{
|
||||||
},
|
type: 'text' as const,
|
||||||
],
|
text: await getController(teamName, claudeDir).tasks.memberBriefing(memberName),
|
||||||
}),
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
server.addTool({
|
server.addTool({
|
||||||
|
|
@ -582,13 +641,16 @@ export function registerTaskTools(server: Pick<FastMCP, 'addTool'>) {
|
||||||
...toolContextSchema,
|
...toolContextSchema,
|
||||||
memberName: z.string().min(1),
|
memberName: z.string().min(1),
|
||||||
}),
|
}),
|
||||||
execute: async ({ teamName, claudeDir, memberName }) => ({
|
execute: async ({ teamName, claudeDir, memberName }) => {
|
||||||
content: [
|
assertConfiguredTeam(teamName, claudeDir);
|
||||||
{
|
return {
|
||||||
type: 'text' as const,
|
content: [
|
||||||
text: await getController(teamName, claudeDir).tasks.taskBriefing(memberName),
|
{
|
||||||
},
|
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 () => {
|
it('caps high-volume task_list inventory over stdio and keeps rows compact', async () => {
|
||||||
await writeTeamConfig(claudeDir, 'bulk-inventory-team');
|
await writeTeamConfig(claudeDir, 'bulk-inventory-team');
|
||||||
await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225);
|
await writeBulkTaskRows(claudeDir, 'bulk-inventory-team', 225);
|
||||||
|
|
@ -1909,6 +1955,38 @@ describe('agent-teams-mcp stdio e2e', () => {
|
||||||
const startDeletedErrorText =
|
const startDeletedErrorText =
|
||||||
startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? '');
|
startDeletedResponse.error?.message ?? (startDeletedResponse.result?.content?.[0]?.text ?? '');
|
||||||
expect(startDeletedErrorText).toContain('use task_restore before starting work');
|
expect(startDeletedErrorText).toContain('use task_restore before starting work');
|
||||||
|
|
||||||
|
const restoreResult = await client.callTool(
|
||||||
|
'task_restore',
|
||||||
|
{
|
||||||
|
claudeDir,
|
||||||
|
teamName: 'stdio-hardening-team',
|
||||||
|
taskId: task.id,
|
||||||
|
actor: 'team-lead',
|
||||||
|
},
|
||||||
|
110
|
||||||
|
);
|
||||||
|
const restored = parseJsonToolResult((restoreResult as { result: unknown }).result);
|
||||||
|
expect(restored.status).toBe('pending');
|
||||||
|
expect(restored.reviewState).toBe('none');
|
||||||
|
|
||||||
|
const restoreAgainResult = await client.callTool(
|
||||||
|
'task_restore',
|
||||||
|
{
|
||||||
|
claudeDir,
|
||||||
|
teamName: 'stdio-hardening-team',
|
||||||
|
taskId: task.id,
|
||||||
|
actor: 'team-lead',
|
||||||
|
},
|
||||||
|
111
|
||||||
|
);
|
||||||
|
const restoreAgainResponse = restoreAgainResult as {
|
||||||
|
error?: { message?: string };
|
||||||
|
result?: { content?: Array<{ text?: string }> };
|
||||||
|
};
|
||||||
|
const restoreAgainErrorText =
|
||||||
|
restoreAgainResponse.error?.message ?? (restoreAgainResponse.result?.content?.[0]?.text ?? '');
|
||||||
|
expect(restoreAgainErrorText).toContain('task_restore only restores deleted tasks');
|
||||||
} finally {
|
} finally {
|
||||||
await client.close();
|
await client.close();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -88,7 +88,9 @@ describe('agent-teams-mcp tools', () => {
|
||||||
res.end(JSON.stringify(result.body));
|
res.end(JSON.stringify(result.body));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
res.writeHead(500, { 'content-type': 'application/json' });
|
res.writeHead(500, { 'content-type': 'application/json' });
|
||||||
res.end(JSON.stringify({ error: error instanceof Error ? error.message : String(error) }));
|
res.end(
|
||||||
|
JSON.stringify({ error: error instanceof Error ? error.message : String(error) })
|
||||||
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -125,6 +127,10 @@ describe('agent-teams-mcp tools', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('launches and stops teams through the runtime MCP tools', async () => {
|
it('launches and stops teams through the runtime MCP tools', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
writeTeamConfig(claudeDir, 'alpha', {
|
||||||
|
members: [{ name: 'lead', role: 'team-lead' }],
|
||||||
|
});
|
||||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||||
const server = await startControlServer(async ({ method, url, body }) => {
|
const server = await startControlServer(async ({ method, url, body }) => {
|
||||||
calls.push({ method, url, body });
|
calls.push({ method, url, body });
|
||||||
|
|
@ -171,6 +177,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
try {
|
try {
|
||||||
const launched = parseJsonToolResult(
|
const launched = parseJsonToolResult(
|
||||||
await getTool('team_launch').execute({
|
await getTool('team_launch').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
cwd: '/tmp/project',
|
cwd: '/tmp/project',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
|
|
@ -182,6 +189,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
|
|
||||||
const stopped = parseJsonToolResult(
|
const stopped = parseJsonToolResult(
|
||||||
await getTool('team_stop').execute({
|
await getTool('team_stop').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
})
|
})
|
||||||
|
|
@ -216,6 +224,13 @@ describe('agent-teams-mcp tools', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => {
|
it('forwards OpenCode runtime MCP tools through the runtime control bridge', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
writeTeamConfig(claudeDir, 'alpha', {
|
||||||
|
members: [
|
||||||
|
{ name: 'lead', role: 'team-lead' },
|
||||||
|
{ name: 'alice', role: 'developer' },
|
||||||
|
],
|
||||||
|
});
|
||||||
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
const calls: Array<{ method?: string; url?: string; body?: unknown }> = [];
|
||||||
const server = await startControlServer(async ({ method, url, body }) => {
|
const server = await startControlServer(async ({ method, url, body }) => {
|
||||||
calls.push({ method, url, body });
|
calls.push({ method, url, body });
|
||||||
|
|
@ -224,6 +239,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await getTool('runtime_bootstrap_checkin').execute({
|
await getTool('runtime_bootstrap_checkin').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
runId: 'run-oc',
|
runId: 'run-oc',
|
||||||
|
|
@ -231,6 +247,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
runtimeSessionId: 'ses-1',
|
runtimeSessionId: 'ses-1',
|
||||||
});
|
});
|
||||||
await getTool('runtime_deliver_message').execute({
|
await getTool('runtime_deliver_message').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
idempotencyKey: 'idem-1',
|
idempotencyKey: 'idem-1',
|
||||||
|
|
@ -241,6 +258,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
text: 'hello',
|
text: 'hello',
|
||||||
});
|
});
|
||||||
await getTool('runtime_task_event').execute({
|
await getTool('runtime_task_event').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
idempotencyKey: 'idem-task-1',
|
idempotencyKey: 'idem-task-1',
|
||||||
|
|
@ -251,6 +269,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
event: 'started',
|
event: 'started',
|
||||||
});
|
});
|
||||||
await getTool('runtime_heartbeat').execute({
|
await getTool('runtime_heartbeat').execute({
|
||||||
|
claudeDir,
|
||||||
teamName: 'alpha',
|
teamName: 'alpha',
|
||||||
controlUrl: server.baseUrl,
|
controlUrl: server.baseUrl,
|
||||||
runId: 'run-oc',
|
runId: 'run-oc',
|
||||||
|
|
@ -280,6 +299,9 @@ describe('agent-teams-mcp tools', () => {
|
||||||
|
|
||||||
it('discovers the control endpoint from the published state file', async () => {
|
it('discovers the control endpoint from the published state file', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
|
writeTeamConfig(claudeDir, 'alpha', {
|
||||||
|
members: [{ name: 'lead', role: 'team-lead' }],
|
||||||
|
});
|
||||||
const statePath = path.join(claudeDir, 'team-control-api.json');
|
const statePath = path.join(claudeDir, 'team-control-api.json');
|
||||||
|
|
||||||
const server = await startControlServer(async ({ method, url }) => {
|
const server = await startControlServer(async ({ method, url }) => {
|
||||||
|
|
@ -648,12 +670,16 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(ownerInbox[0].text).toContain('task_start');
|
expect(ownerInbox[0].text).toContain('task_start');
|
||||||
expect(ownerInbox[0].text).toContain('task_add_comment');
|
expect(ownerInbox[0].text).toContain('task_add_comment');
|
||||||
expect(ownerInbox[0].text).toContain('Read the plan before starting.');
|
expect(ownerInbox[0].text).toContain('Read the plan before starting.');
|
||||||
expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.');
|
expect(ownerInbox[0].text).toContain(
|
||||||
|
'If you are idle and this task is ready to start, start it now.'
|
||||||
|
);
|
||||||
expect(ownerInbox[0].text).toContain(
|
expect(ownerInbox[0].text).toContain(
|
||||||
'If you are busy, blocked, or still need more context, immediately add a short task comment'
|
'If you are busy, blocked, or still need more context, immediately add a short task comment'
|
||||||
);
|
);
|
||||||
expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`);
|
expect(ownerInbox[3].summary).toContain(`#${unassignedTask.displayId}`);
|
||||||
expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.');
|
expect(ownerInbox[3].text).toContain(
|
||||||
|
'If you are idle and this task is ready to start, start it now.'
|
||||||
|
);
|
||||||
expect(ownerInbox[3].text).toContain('task_add_comment');
|
expect(ownerInbox[3].text).toContain('task_add_comment');
|
||||||
|
|
||||||
const briefing = (await getTool('task_briefing').execute({
|
const briefing = (await getTool('task_briefing').execute({
|
||||||
|
|
@ -695,14 +721,22 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(memberBriefingText).toContain(
|
expect(memberBriefingText).toContain(
|
||||||
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
|
'Awareness items are watch-only context and do not authorize you to start work unless the lead reroutes the task or you become the actionOwner.'
|
||||||
);
|
);
|
||||||
expect(memberBriefingText).toContain('TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):');
|
expect(memberBriefingText).toContain(
|
||||||
|
'TURN ACTION MODE PROTOCOL (HIGHEST PRIORITY FOR EACH USER TURN):'
|
||||||
|
);
|
||||||
expect(memberBriefingText).toContain('Task briefing for alice:');
|
expect(memberBriefingText).toContain('Task briefing for alice:');
|
||||||
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
|
expect(memberBriefingText).toContain(`#${activeTask.displayId}`);
|
||||||
|
|
||||||
fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true });
|
fs.mkdirSync(path.join(claudeDir, 'teams', teamName, 'inboxes'), { recursive: true });
|
||||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'carol.json'), '[]');
|
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'carol.json'), '[]');
|
||||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'), '[]');
|
fs.writeFileSync(
|
||||||
fs.writeFileSync(path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'), '[]');
|
path.join(claudeDir, 'teams', teamName, 'inboxes', 'cross_team_send.json'),
|
||||||
|
'[]'
|
||||||
|
);
|
||||||
|
fs.writeFileSync(
|
||||||
|
path.join(claudeDir, 'teams', teamName, 'inboxes', 'other-team.alice.json'),
|
||||||
|
'[]'
|
||||||
|
);
|
||||||
|
|
||||||
const inboxResolvedBriefing = (await getTool('member_briefing').execute({
|
const inboxResolvedBriefing = (await getTool('member_briefing').execute({
|
||||||
claudeDir,
|
claudeDir,
|
||||||
|
|
@ -710,7 +744,9 @@ describe('agent-teams-mcp tools', () => {
|
||||||
memberName: 'carol',
|
memberName: 'carol',
|
||||||
})) as { content: Array<{ text: string }> };
|
})) as { content: Array<{ text: string }> };
|
||||||
const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? '';
|
const inboxResolvedBriefingText = inboxResolvedBriefing.content[0]?.text ?? '';
|
||||||
expect(inboxResolvedBriefingText).toContain('Member briefing for carol on team "gamma" (gamma).');
|
expect(inboxResolvedBriefingText).toContain(
|
||||||
|
'Member briefing for carol on team "gamma" (gamma).'
|
||||||
|
);
|
||||||
expect(inboxResolvedBriefingText).toContain('Role: team member.');
|
expect(inboxResolvedBriefingText).toContain('Role: team member.');
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
|
|
@ -897,9 +933,9 @@ describe('agent-teams-mcp tools', () => {
|
||||||
teamName,
|
teamName,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState).toBe(
|
expect(
|
||||||
'needsFix'
|
listedTasks.find((task: { id: string }) => task.id === createdTask.id)?.reviewState
|
||||||
);
|
).toBe('needsFix');
|
||||||
|
|
||||||
const kanbanCleared = parseJsonToolResult(
|
const kanbanCleared = parseJsonToolResult(
|
||||||
await getTool('kanban_clear').execute({
|
await getTool('kanban_clear').execute({
|
||||||
|
|
@ -1044,6 +1080,26 @@ describe('agent-teams-mcp tools', () => {
|
||||||
);
|
);
|
||||||
expect(kanbanState.tasks[reviewTask.id]).toBeUndefined();
|
expect(kanbanState.tasks[reviewTask.id]).toBeUndefined();
|
||||||
expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id);
|
expect(JSON.stringify(kanbanState.columnOrder ?? {})).not.toContain(reviewTask.id);
|
||||||
|
|
||||||
|
const restored = parseJsonToolResult(
|
||||||
|
await getTool('task_restore').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
taskId: reviewTask.id,
|
||||||
|
actor: 'lead',
|
||||||
|
})
|
||||||
|
);
|
||||||
|
expect(restored.status).toBe('pending');
|
||||||
|
expect(restored.reviewState).toBe('none');
|
||||||
|
|
||||||
|
await expect(
|
||||||
|
getTool('task_restore').execute({
|
||||||
|
claudeDir,
|
||||||
|
teamName,
|
||||||
|
taskId: reviewTask.id,
|
||||||
|
actor: 'lead',
|
||||||
|
})
|
||||||
|
).rejects.toThrow('task_restore only restores deleted tasks');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('only notifies the owner on review_approve when notifyOwner is explicit', async () => {
|
it('only notifies the owner on review_approve when notifyOwner is explicit', async () => {
|
||||||
|
|
@ -1132,6 +1188,12 @@ describe('agent-teams-mcp tools', () => {
|
||||||
it('persists full message metadata through message_send', async () => {
|
it('persists full message metadata through message_send', async () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const teamName = 'gamma';
|
const teamName = 'gamma';
|
||||||
|
writeTeamConfig(claudeDir, teamName, {
|
||||||
|
members: [
|
||||||
|
{ name: 'lead', role: 'team-lead' },
|
||||||
|
{ name: 'alice', role: 'developer' },
|
||||||
|
],
|
||||||
|
});
|
||||||
|
|
||||||
const sent = parseJsonToolResult(
|
const sent = parseJsonToolResult(
|
||||||
await getTool('message_send').execute({
|
await getTool('message_send').execute({
|
||||||
|
|
@ -1155,6 +1217,41 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(rows[0].attachments[0].filename).toBe('note.txt');
|
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', () => {
|
it('exposes zod schemas that reject obviously invalid payloads', () => {
|
||||||
expect(
|
expect(
|
||||||
getTool('task_create').parameters?.safeParse({
|
getTool('task_create').parameters?.safeParse({
|
||||||
|
|
@ -1303,9 +1400,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
expect(completed.comments).toBeUndefined();
|
expect(completed.comments).toBeUndefined();
|
||||||
|
|
||||||
// task_list: explicit inventory shape only
|
// task_list: explicit inventory shape only
|
||||||
const listed = parseJsonToolResult(
|
const listed = parseJsonToolResult(await getTool('task_list').execute({ claudeDir, teamName }));
|
||||||
await getTool('task_list').execute({ claudeDir, teamName })
|
|
||||||
);
|
|
||||||
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
|
const listedTask = listed.find((t: { id: string }) => t.id === task.id);
|
||||||
expect(listedTask).toBeDefined();
|
expect(listedTask).toBeDefined();
|
||||||
expect(listedTask).toEqual({
|
expect(listedTask).toEqual({
|
||||||
|
|
@ -1345,9 +1440,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json');
|
const sentPath = path.join(claudeDir, 'teams', teamName, 'sentMessages.json');
|
||||||
const teamDir = path.join(claudeDir, 'teams', teamName);
|
const teamDir = path.join(claudeDir, 'teams', teamName);
|
||||||
fs.mkdirSync(teamDir, { recursive: true });
|
fs.mkdirSync(teamDir, { recursive: true });
|
||||||
const existing = fs.existsSync(sentPath)
|
const existing = fs.existsSync(sentPath) ? JSON.parse(fs.readFileSync(sentPath, 'utf8')) : [];
|
||||||
? JSON.parse(fs.readFileSync(sentPath, 'utf8'))
|
|
||||||
: [];
|
|
||||||
existing.push(message);
|
existing.push(message);
|
||||||
fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2));
|
fs.writeFileSync(sentPath, JSON.stringify(existing, null, 2));
|
||||||
}
|
}
|
||||||
|
|
@ -1693,9 +1786,7 @@ describe('agent-teams-mcp tools', () => {
|
||||||
text: 'Roundtrip test message',
|
text: 'Roundtrip test message',
|
||||||
timestamp: '2026-03-15T16:00:00.000Z',
|
timestamp: '2026-03-15T16:00:00.000Z',
|
||||||
source: 'user_sent',
|
source: 'user_sent',
|
||||||
attachments: [
|
attachments: [{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 }],
|
||||||
{ id: 'att-rt', filename: 'data.csv', mimeType: 'text/csv', size: 1024 },
|
|
||||||
],
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const created = parseJsonToolResult(
|
const created = parseJsonToolResult(
|
||||||
|
|
@ -1813,4 +1904,20 @@ describe('agent-teams-mcp tools', () => {
|
||||||
'Unknown team "team-lead". Board tools require an existing configured team with config.json.'
|
'Unknown team "team-lead". Board tools require an existing configured team with config.json.'
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('fails closed for primary queue and inventory tools when team config does not exist', async () => {
|
||||||
|
const claudeDir = makeClaudeDir();
|
||||||
|
const params = { claudeDir, teamName: 'team-lead' };
|
||||||
|
const expected =
|
||||||
|
'Unknown team "team-lead". Board tools require an existing configured team with config.json.';
|
||||||
|
|
||||||
|
await expect(getTool('lead_briefing').execute(params)).rejects.toThrow(expected);
|
||||||
|
await expect(
|
||||||
|
getTool('task_briefing').execute({
|
||||||
|
...params,
|
||||||
|
memberName: 'alice',
|
||||||
|
})
|
||||||
|
).rejects.toThrow(expected);
|
||||||
|
await expect(getTool('task_list').execute(params)).rejects.toThrow(expected);
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ import type {
|
||||||
} from '../ports/RecentProjectsSourcePort';
|
} from '../ports/RecentProjectsSourcePort';
|
||||||
|
|
||||||
const DEFAULT_CACHE_TTL_MS = 10_000;
|
const DEFAULT_CACHE_TTL_MS = 10_000;
|
||||||
const DEFAULT_DEGRADED_CACHE_TTL_MS = 1_500;
|
const DEFAULT_DEGRADED_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
interface SourceLoadResult {
|
interface SourceLoadResult {
|
||||||
candidates: RecentProjectCandidate[];
|
candidates: RecentProjectCandidate[];
|
||||||
|
|
@ -99,9 +99,7 @@ export class ListDashboardRecentProjectsUseCase<TViewModel> {
|
||||||
}
|
}
|
||||||
|
|
||||||
const viewModel = this.deps.output.present(response);
|
const viewModel = this.deps.output.present(response);
|
||||||
const cacheTtlMs = hasDegradedSources
|
const cacheTtlMs = hasDegradedSources ? this.#degradedCacheTtlMs : this.#cacheTtlMs;
|
||||||
? Math.min(this.#cacheTtlMs, this.#degradedCacheTtlMs)
|
|
||||||
: this.#cacheTtlMs;
|
|
||||||
|
|
||||||
await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
|
await this.deps.cache.set(cacheKey, viewModel, cacheTtlMs);
|
||||||
this.deps.logger.info('recent-projects loaded', {
|
this.deps.logger.info('recent-projects loaded', {
|
||||||
|
|
|
||||||
|
|
@ -16,16 +16,26 @@ import type {
|
||||||
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
|
import type { RecentProjectIdentityResolver } from '@features/recent-projects/main/infrastructure/identity/RecentProjectIdentityResolver';
|
||||||
import type { ServiceContext } from '@main/services';
|
import type { ServiceContext } from '@main/services';
|
||||||
|
|
||||||
const CODEX_THREAD_LIMIT = 40;
|
const CODEX_THREAD_LIMIT = 20;
|
||||||
const CODEX_INITIALIZE_TIMEOUT_MS = 6_000;
|
const CODEX_INITIALIZE_TIMEOUT_MS = 6_000;
|
||||||
const CODEX_LIVE_FETCH_TIMEOUT_MS = 4_500;
|
const CODEX_LIVE_FETCH_TIMEOUT_MS = 12_000;
|
||||||
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 2_500;
|
const CODEX_ARCHIVED_FETCH_TIMEOUT_MS = 4_000;
|
||||||
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
const CODEX_SESSION_OVERHEAD_TIMEOUT_MS = 1_500;
|
||||||
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
|
const CODEX_TOTAL_FETCH_TIMEOUT_MS =
|
||||||
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
CODEX_INITIALIZE_TIMEOUT_MS +
|
||||||
|
CODEX_ARCHIVED_FETCH_TIMEOUT_MS +
|
||||||
|
CODEX_LIVE_FETCH_TIMEOUT_MS +
|
||||||
|
CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||||
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
|
const CODEX_SOURCE_TIMEOUT_MS = CODEX_TOTAL_FETCH_TIMEOUT_MS + 500;
|
||||||
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
|
const CODEX_LIVE_ONLY_FALLBACK_TOTAL_TIMEOUT_MS =
|
||||||
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
CODEX_INITIALIZE_TIMEOUT_MS + CODEX_LIVE_FETCH_TIMEOUT_MS + CODEX_SESSION_OVERHEAD_TIMEOUT_MS;
|
||||||
|
const CODEX_STALE_CANDIDATES_TTL_MS = 5 * 60_000;
|
||||||
|
const CODEX_FULL_FAILURE_COOLDOWN_MS = 30_000;
|
||||||
|
|
||||||
|
interface StaleCodexCandidatesSnapshot {
|
||||||
|
candidates: RecentProjectCandidate[];
|
||||||
|
capturedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
function isInteractiveSource(source: unknown): boolean {
|
function isInteractiveSource(source: unknown): boolean {
|
||||||
return source === 'vscode' || source === 'cli';
|
return source === 'vscode' || source === 'cli';
|
||||||
|
|
@ -42,9 +52,24 @@ function isDegradedThreadResult(result: CodexRecentThreadsResult): boolean {
|
||||||
return Boolean(result.live.error || result.archived.error);
|
return Boolean(result.live.error || result.archived.error);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function getFullFailureReason(result: CodexRecentThreadsResult): string | null {
|
||||||
|
if (!result.live.error || !result.archived.error) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.live.error === result.archived.error) {
|
||||||
|
return result.live.error;
|
||||||
|
}
|
||||||
|
|
||||||
|
return `live: ${result.live.error}; archived: ${result.archived.error}`;
|
||||||
|
}
|
||||||
|
|
||||||
export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
|
export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePort {
|
||||||
readonly sourceId = 'codex';
|
readonly sourceId = 'codex';
|
||||||
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
|
readonly timeoutMs = CODEX_SOURCE_TIMEOUT_MS;
|
||||||
|
#staleCandidatesSnapshot: StaleCodexCandidatesSnapshot | null = null;
|
||||||
|
#fullFailureCooldownUntil = 0;
|
||||||
|
#fullFailureCooldownReason: string | null = null;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly deps: {
|
private readonly deps: {
|
||||||
|
|
@ -77,8 +102,19 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const cooldown = this.#getActiveCooldown();
|
||||||
|
if (cooldown) {
|
||||||
|
this.deps.logger.info('codex recent-projects source cooldown active', cooldown);
|
||||||
|
return {
|
||||||
|
candidates: this.#getFreshStaleCandidates() ?? [],
|
||||||
|
degraded: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
|
const threadSegments = await this.#listRecentThreadsSafe(binaryPath);
|
||||||
const degraded = isDegradedThreadResult(threadSegments);
|
const degraded = isDegradedThreadResult(threadSegments);
|
||||||
|
const fullFailureReason = getFullFailureReason(threadSegments);
|
||||||
|
this.#updateFullFailureCooldown(fullFailureReason);
|
||||||
this.#logSegmentFailure(threadSegments, 'live');
|
this.#logSegmentFailure(threadSegments, 'live');
|
||||||
this.#logSegmentFailure(threadSegments, 'archived');
|
this.#logSegmentFailure(threadSegments, 'archived');
|
||||||
const liveThreads = threadSegments.live.threads;
|
const liveThreads = threadSegments.live.threads;
|
||||||
|
|
@ -92,6 +128,25 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
||||||
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
|
await Promise.all(interactiveThreads.map((thread) => this.#toCandidate(thread)))
|
||||||
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
|
).filter((candidate): candidate is RecentProjectCandidate => candidate !== null);
|
||||||
|
|
||||||
|
if (!degraded) {
|
||||||
|
this.#rememberHealthyCandidates(candidates);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (degraded && candidates.length === 0) {
|
||||||
|
const staleCandidates = this.#getFreshStaleCandidates();
|
||||||
|
if (staleCandidates) {
|
||||||
|
this.deps.logger.info('codex recent-projects served stale candidates', {
|
||||||
|
count: staleCandidates.length,
|
||||||
|
reason: fullFailureReason ?? 'degraded-empty-result',
|
||||||
|
});
|
||||||
|
|
||||||
|
return {
|
||||||
|
candidates: staleCandidates,
|
||||||
|
degraded: true,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
this.deps.logger.info('codex recent-projects source loaded', {
|
this.deps.logger.info('codex recent-projects source loaded', {
|
||||||
count: candidates.length,
|
count: candidates.length,
|
||||||
degraded,
|
degraded,
|
||||||
|
|
@ -103,6 +158,53 @@ export class CodexRecentProjectsSourceAdapter implements RecentProjectsSourcePor
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#getActiveCooldown(): { retryAfterMs: number; reason: string | null } | null {
|
||||||
|
const retryAfterMs = this.#fullFailureCooldownUntil - Date.now();
|
||||||
|
if (retryAfterMs <= 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
retryAfterMs,
|
||||||
|
reason: this.#fullFailureCooldownReason,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
#updateFullFailureCooldown(reason: string | null): void {
|
||||||
|
if (!reason) {
|
||||||
|
this.#fullFailureCooldownUntil = 0;
|
||||||
|
this.#fullFailureCooldownReason = null;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
this.#fullFailureCooldownUntil = Date.now() + CODEX_FULL_FAILURE_COOLDOWN_MS;
|
||||||
|
this.#fullFailureCooldownReason = reason;
|
||||||
|
}
|
||||||
|
|
||||||
|
#rememberHealthyCandidates(candidates: RecentProjectCandidate[]): void {
|
||||||
|
this.#staleCandidatesSnapshot =
|
||||||
|
candidates.length > 0
|
||||||
|
? {
|
||||||
|
candidates,
|
||||||
|
capturedAt: Date.now(),
|
||||||
|
}
|
||||||
|
: null;
|
||||||
|
}
|
||||||
|
|
||||||
|
#getFreshStaleCandidates(): RecentProjectCandidate[] | null {
|
||||||
|
const snapshot = this.#staleCandidatesSnapshot;
|
||||||
|
if (!snapshot) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Date.now() - snapshot.capturedAt > CODEX_STALE_CANDIDATES_TTL_MS) {
|
||||||
|
this.#staleCandidatesSnapshot = null;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return [...snapshot.candidates];
|
||||||
|
}
|
||||||
|
|
||||||
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
async #listRecentThreads(binaryPath: string): Promise<CodexRecentThreadsResult> {
|
||||||
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
|
const result = await this.deps.appServerClient.listRecentThreads(binaryPath, {
|
||||||
limit: CODEX_THREAD_LIMIT,
|
limit: CODEX_THREAD_LIMIT,
|
||||||
|
|
|
||||||
|
|
@ -74,10 +74,7 @@ export class CodexAppServerClient {
|
||||||
}
|
}
|
||||||
): Promise<CodexThreadSegmentResult> {
|
): Promise<CodexThreadSegmentResult> {
|
||||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
const initializeTimeoutMs = Math.max(
|
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
||||||
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
|
||||||
requestTimeoutMs
|
|
||||||
);
|
|
||||||
const totalTimeoutMs = Math.max(
|
const totalTimeoutMs = Math.max(
|
||||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||||
initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
initializeTimeoutMs + requestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||||
|
|
@ -122,13 +119,13 @@ export class CodexAppServerClient {
|
||||||
const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
const liveRequestTimeoutMs = options.liveRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
const archivedRequestTimeoutMs = options.archivedRequestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
const sessionRequestTimeoutMs = Math.max(liveRequestTimeoutMs, archivedRequestTimeoutMs);
|
||||||
const initializeTimeoutMs = Math.max(
|
const initializeTimeoutMs = options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS;
|
||||||
options.initializeTimeoutMs ?? DEFAULT_INITIALIZE_TIMEOUT_MS,
|
|
||||||
sessionRequestTimeoutMs
|
|
||||||
);
|
|
||||||
const totalTimeoutMs = Math.max(
|
const totalTimeoutMs = Math.max(
|
||||||
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
options.totalTimeoutMs ?? DEFAULT_TOTAL_TIMEOUT_MS,
|
||||||
initializeTimeoutMs + sessionRequestTimeoutMs + MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
initializeTimeoutMs +
|
||||||
|
liveRequestTimeoutMs +
|
||||||
|
archivedRequestTimeoutMs +
|
||||||
|
MIN_SESSION_OVERHEAD_TIMEOUT_MS
|
||||||
);
|
);
|
||||||
|
|
||||||
return this.#withThreadListSession(
|
return this.#withThreadListSession(
|
||||||
|
|
@ -140,50 +137,55 @@ export class CodexAppServerClient {
|
||||||
label: 'codex app-server thread/list',
|
label: 'codex app-server thread/list',
|
||||||
},
|
},
|
||||||
async (session) => {
|
async (session) => {
|
||||||
const [live, archived] = await Promise.allSettled([
|
const live = await this.#requestThreadListSegment(session, {
|
||||||
session.request<ThreadListResponse>(
|
archived: false,
|
||||||
'thread/list',
|
limit: options.limit,
|
||||||
{
|
timeoutMs: liveRequestTimeoutMs,
|
||||||
archived: false,
|
});
|
||||||
limit: options.limit,
|
const archived = await this.#requestThreadListSegment(session, {
|
||||||
sortKey: 'updated_at',
|
archived: true,
|
||||||
},
|
limit: options.limit,
|
||||||
liveRequestTimeoutMs
|
timeoutMs: archivedRequestTimeoutMs,
|
||||||
),
|
});
|
||||||
session.request<ThreadListResponse>(
|
|
||||||
'thread/list',
|
|
||||||
{
|
|
||||||
archived: true,
|
|
||||||
limit: options.limit,
|
|
||||||
sortKey: 'updated_at',
|
|
||||||
},
|
|
||||||
archivedRequestTimeoutMs
|
|
||||||
),
|
|
||||||
]);
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
live:
|
live,
|
||||||
live.status === 'fulfilled'
|
archived,
|
||||||
? { threads: live.value.data ?? [] }
|
|
||||||
: {
|
|
||||||
threads: [],
|
|
||||||
error: live.reason instanceof Error ? live.reason.message : String(live.reason),
|
|
||||||
},
|
|
||||||
archived:
|
|
||||||
archived.status === 'fulfilled'
|
|
||||||
? { threads: archived.value.data ?? [] }
|
|
||||||
: {
|
|
||||||
threads: [],
|
|
||||||
error:
|
|
||||||
archived.reason instanceof Error
|
|
||||||
? archived.reason.message
|
|
||||||
: String(archived.reason),
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async #requestThreadListSegment(
|
||||||
|
session: JsonRpcSession,
|
||||||
|
options: {
|
||||||
|
archived: boolean;
|
||||||
|
limit: number;
|
||||||
|
timeoutMs: number;
|
||||||
|
}
|
||||||
|
): Promise<CodexThreadSegmentResult> {
|
||||||
|
try {
|
||||||
|
const response = await session.request<ThreadListResponse>(
|
||||||
|
'thread/list',
|
||||||
|
{
|
||||||
|
archived: options.archived,
|
||||||
|
limit: options.limit,
|
||||||
|
sortKey: 'updated_at',
|
||||||
|
},
|
||||||
|
options.timeoutMs
|
||||||
|
);
|
||||||
|
|
||||||
|
return {
|
||||||
|
threads: response.data ?? [],
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return {
|
||||||
|
threads: [],
|
||||||
|
error: error instanceof Error ? error.message : String(error),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async #withThreadListSession<T>(
|
async #withThreadListSession<T>(
|
||||||
options: ThreadListSessionOptions,
|
options: ThreadListSessionOptions,
|
||||||
handler: (session: JsonRpcSession) => Promise<T>
|
handler: (session: JsonRpcSession) => Promise<T>
|
||||||
|
|
|
||||||
|
|
@ -25,8 +25,8 @@ import type { TeamSummary } from '@shared/types';
|
||||||
|
|
||||||
const INITIAL_RECENT_PROJECTS = 11;
|
const INITIAL_RECENT_PROJECTS = 11;
|
||||||
const LOAD_MORE_STEP = 8;
|
const LOAD_MORE_STEP = 8;
|
||||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 1_500;
|
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_DELAY_MS = 30_000;
|
||||||
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 5_000;
|
const DEGRADED_RECENT_PROJECTS_STEADY_RETRY_DELAY_MS = 120_000;
|
||||||
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3;
|
const DEGRADED_RECENT_PROJECTS_FAST_RETRY_LIMIT = 3;
|
||||||
|
|
||||||
function matchesSearch(project: DashboardRecentProject, query: string): boolean {
|
function matchesSearch(project: DashboardRecentProject, query: string): boolean {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import type {
|
||||||
} from '@features/recent-projects/contracts';
|
} from '@features/recent-projects/contracts';
|
||||||
|
|
||||||
const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
|
const RECENT_PROJECTS_CLIENT_CACHE_TTL_MS = 15_000;
|
||||||
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 1_500;
|
const RECENT_PROJECTS_CLIENT_DEGRADED_CACHE_TTL_MS = 30_000;
|
||||||
|
|
||||||
let cachedPayload: DashboardRecentProjectsPayloadLike = null;
|
let cachedPayload: DashboardRecentProjectsPayloadLike = null;
|
||||||
let cachedAt = 0;
|
let cachedAt = 0;
|
||||||
|
|
|
||||||
|
|
@ -76,6 +76,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 0,
|
runtimeAlivePendingCount: 0,
|
||||||
|
shellOnlyPendingCount: 0,
|
||||||
|
runtimeProcessPendingCount: 0,
|
||||||
|
runtimeCandidatePendingCount: 0,
|
||||||
|
noRuntimePendingCount: 0,
|
||||||
|
permissionPendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
||||||
});
|
});
|
||||||
|
|
@ -130,6 +135,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
runtimePid: 333,
|
runtimePid: 333,
|
||||||
|
runtimeSessionId: 'session-bob',
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
pidSource: 'runtime_bootstrap',
|
||||||
|
runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted',
|
||||||
|
runtimeDiagnosticSeverity: 'info',
|
||||||
diagnostics: ['spawn accepted', 'late heartbeat received'],
|
diagnostics: ['spawn accepted', 'late heartbeat received'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -145,12 +155,22 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
runtimeAlive: true,
|
runtimeAlive: true,
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
runtimePid: 333,
|
runtimePid: 333,
|
||||||
|
runtimeSessionId: 'session-bob',
|
||||||
|
livenessKind: 'confirmed_bootstrap',
|
||||||
|
pidSource: 'runtime_bootstrap',
|
||||||
|
runtimeDiagnostic: 'OpenCode runtime bootstrap check-in accepted',
|
||||||
|
runtimeDiagnosticSeverity: 'info',
|
||||||
});
|
});
|
||||||
expect(snapshot.summary).toEqual({
|
expect(snapshot.summary).toEqual({
|
||||||
confirmedCount: 2,
|
confirmedCount: 2,
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 0,
|
runtimeAlivePendingCount: 0,
|
||||||
|
shellOnlyPendingCount: 0,
|
||||||
|
runtimeProcessPendingCount: 0,
|
||||||
|
runtimeCandidatePendingCount: 0,
|
||||||
|
noRuntimePendingCount: 0,
|
||||||
|
permissionPendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(snapshot.teamLaunchState).toBe('clean_success');
|
expect(snapshot.teamLaunchState).toBe('clean_success');
|
||||||
});
|
});
|
||||||
|
|
@ -229,6 +249,11 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
pendingCount: 0,
|
pendingCount: 0,
|
||||||
failedCount: 1,
|
failedCount: 1,
|
||||||
runtimeAlivePendingCount: 0,
|
runtimeAlivePendingCount: 0,
|
||||||
|
shellOnlyPendingCount: 0,
|
||||||
|
runtimeProcessPendingCount: 0,
|
||||||
|
runtimeCandidatePendingCount: 0,
|
||||||
|
noRuntimePendingCount: 0,
|
||||||
|
permissionPendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(snapshot.teamLaunchState).toBe('partial_failure');
|
expect(snapshot.teamLaunchState).toBe('partial_failure');
|
||||||
});
|
});
|
||||||
|
|
@ -279,9 +304,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
evidence: {
|
evidence: {
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
|
livenessKind: 'permission_blocked',
|
||||||
|
runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
|
||||||
|
runtimeDiagnosticSeverity: 'warning',
|
||||||
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -292,9 +320,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
laneKind: 'secondary',
|
laneKind: 'secondary',
|
||||||
laneOwnerProviderId: 'opencode',
|
laneOwnerProviderId: 'opencode',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
|
livenessKind: 'permission_blocked',
|
||||||
|
runtimeDiagnostic: 'OpenCode runtime is waiting for permission approval',
|
||||||
|
runtimeDiagnosticSeverity: 'warning',
|
||||||
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
pendingPermissionRequestIds: ['opencode:run-1:perm_1'],
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
|
|
@ -303,7 +334,12 @@ describe('buildMixedPersistedLaunchSnapshot', () => {
|
||||||
confirmedCount: 1,
|
confirmedCount: 1,
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
|
shellOnlyPendingCount: 0,
|
||||||
|
runtimeProcessPendingCount: 0,
|
||||||
|
runtimeCandidatePendingCount: 0,
|
||||||
|
noRuntimePendingCount: 0,
|
||||||
|
permissionPendingCount: 1,
|
||||||
});
|
});
|
||||||
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
expect(snapshot.teamLaunchState).toBe('partial_pending');
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -11,6 +11,9 @@ import type {
|
||||||
PersistedTeamLaunchPhase,
|
PersistedTeamLaunchPhase,
|
||||||
PersistedTeamLaunchSnapshot,
|
PersistedTeamLaunchSnapshot,
|
||||||
ProviderModelLaunchIdentity,
|
ProviderModelLaunchIdentity,
|
||||||
|
TeamAgentRuntimeDiagnosticSeverity,
|
||||||
|
TeamAgentRuntimeLivenessKind,
|
||||||
|
TeamAgentRuntimePidSource,
|
||||||
TeamFastMode,
|
TeamFastMode,
|
||||||
TeamProviderBackendId,
|
TeamProviderBackendId,
|
||||||
TeamProviderId,
|
TeamProviderId,
|
||||||
|
|
@ -38,6 +41,12 @@ export interface MixedSecondaryLaneMemberStateInput {
|
||||||
hardFailureReason?: string;
|
hardFailureReason?: string;
|
||||||
pendingPermissionRequestIds?: string[];
|
pendingPermissionRequestIds?: string[];
|
||||||
runtimePid?: number;
|
runtimePid?: number;
|
||||||
|
runtimeSessionId?: string;
|
||||||
|
sessionId?: string;
|
||||||
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
|
pidSource?: TeamAgentRuntimePidSource;
|
||||||
|
runtimeDiagnostic?: string;
|
||||||
|
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
} | null;
|
} | null;
|
||||||
pendingReason?: string;
|
pendingReason?: string;
|
||||||
|
|
@ -65,6 +74,19 @@ function deriveMemberLaunchState(params: {
|
||||||
return 'starting';
|
return 'starting';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function preservesStrongRuntimeAlive(value: {
|
||||||
|
runtimeAlive?: boolean;
|
||||||
|
bootstrapConfirmed?: boolean;
|
||||||
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
|
}): boolean {
|
||||||
|
return (
|
||||||
|
value.runtimeAlive === true &&
|
||||||
|
(value.bootstrapConfirmed === true ||
|
||||||
|
value.livenessKind === 'confirmed_bootstrap' ||
|
||||||
|
value.livenessKind === 'runtime_process')
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function buildDiagnostics(
|
function buildDiagnostics(
|
||||||
member: Pick<
|
member: Pick<
|
||||||
PersistedTeamLaunchMemberState,
|
PersistedTeamLaunchMemberState,
|
||||||
|
|
@ -93,14 +115,17 @@ function buildDiagnostics(
|
||||||
}
|
}
|
||||||
|
|
||||||
function createSourcesFromStatus(
|
function createSourcesFromStatus(
|
||||||
status: Pick<MemberSpawnStatusEntry, 'livenessSource' | 'runtimeAlive'>
|
status: Pick<
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
|
'livenessSource' | 'runtimeAlive' | 'bootstrapConfirmed' | 'livenessKind'
|
||||||
|
>
|
||||||
): PersistedTeamLaunchMemberSources | undefined {
|
): PersistedTeamLaunchMemberSources | undefined {
|
||||||
const sources: PersistedTeamLaunchMemberSources = {};
|
const sources: PersistedTeamLaunchMemberSources = {};
|
||||||
if (status.livenessSource === 'heartbeat') {
|
if (status.livenessSource === 'heartbeat') {
|
||||||
sources.nativeHeartbeat = true;
|
sources.nativeHeartbeat = true;
|
||||||
sources.inboxHeartbeat = true;
|
sources.inboxHeartbeat = true;
|
||||||
}
|
}
|
||||||
if (status.livenessSource === 'process' || status.runtimeAlive) {
|
if (status.livenessSource === 'process' && preservesStrongRuntimeAlive(status)) {
|
||||||
sources.processAlive = true;
|
sources.processAlive = true;
|
||||||
}
|
}
|
||||||
return Object.values(sources).some(Boolean) ? sources : undefined;
|
return Object.values(sources).some(Boolean) ? sources : undefined;
|
||||||
|
|
@ -119,6 +144,7 @@ function createPrimaryLaneMemberState(params: {
|
||||||
const providerId =
|
const providerId =
|
||||||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||||
const runtime = params.status;
|
const runtime = params.status;
|
||||||
|
const strongRuntimeAlive = preservesStrongRuntimeAlive(runtime ?? {});
|
||||||
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
|
const sources = runtime ? createSourcesFromStatus(runtime) : undefined;
|
||||||
const base: PersistedTeamLaunchMemberState = {
|
const base: PersistedTeamLaunchMemberState = {
|
||||||
name: params.member.name.trim(),
|
name: params.member.name.trim(),
|
||||||
|
|
@ -151,21 +177,25 @@ function createPrimaryLaneMemberState(params: {
|
||||||
deriveMemberLaunchState({
|
deriveMemberLaunchState({
|
||||||
hardFailure: runtime?.hardFailure,
|
hardFailure: runtime?.hardFailure,
|
||||||
bootstrapConfirmed: runtime?.bootstrapConfirmed,
|
bootstrapConfirmed: runtime?.bootstrapConfirmed,
|
||||||
runtimeAlive: runtime?.runtimeAlive,
|
runtimeAlive: strongRuntimeAlive,
|
||||||
agentToolAccepted: runtime?.agentToolAccepted,
|
agentToolAccepted: runtime?.agentToolAccepted,
|
||||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
|
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds,
|
||||||
}),
|
}),
|
||||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||||
runtimeAlive: runtime?.runtimeAlive === true,
|
runtimeAlive: strongRuntimeAlive,
|
||||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||||
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
pendingPermissionRequestIds: runtime?.pendingPermissionRequestIds?.length
|
||||||
? [...new Set(runtime.pendingPermissionRequestIds)]
|
? [...new Set(runtime.pendingPermissionRequestIds)]
|
||||||
: undefined,
|
: undefined,
|
||||||
|
livenessKind: runtime?.livenessKind,
|
||||||
|
runtimeDiagnostic: runtime?.runtimeDiagnostic,
|
||||||
|
runtimeDiagnosticSeverity: runtime?.runtimeDiagnosticSeverity,
|
||||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? params.updatedAt : undefined,
|
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
|
||||||
|
lastRuntimeAliveAt: preservesStrongRuntimeAlive(runtime ?? {}) ? params.updatedAt : undefined,
|
||||||
lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt,
|
lastEvaluatedAt: runtime?.updatedAt ?? params.updatedAt,
|
||||||
sources,
|
sources,
|
||||||
diagnostics: undefined,
|
diagnostics: undefined,
|
||||||
|
|
@ -180,13 +210,14 @@ function createSecondaryLaneMemberState(
|
||||||
const providerId =
|
const providerId =
|
||||||
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
normalizeOptionalTeamProviderId(params.member.providerId) ?? params.leadDefaults.providerId;
|
||||||
const evidence = params.evidence;
|
const evidence = params.evidence;
|
||||||
|
const strongRuntimeAlive = preservesStrongRuntimeAlive(evidence ?? {});
|
||||||
const hardFailureReason = evidence?.hardFailureReason;
|
const hardFailureReason = evidence?.hardFailureReason;
|
||||||
const launchState =
|
const launchState =
|
||||||
evidence?.launchState ??
|
evidence?.launchState ??
|
||||||
deriveMemberLaunchState({
|
deriveMemberLaunchState({
|
||||||
hardFailure: evidence?.hardFailure,
|
hardFailure: evidence?.hardFailure,
|
||||||
bootstrapConfirmed: evidence?.bootstrapConfirmed,
|
bootstrapConfirmed: evidence?.bootstrapConfirmed,
|
||||||
runtimeAlive: evidence?.runtimeAlive,
|
runtimeAlive: strongRuntimeAlive,
|
||||||
agentToolAccepted: evidence?.agentToolAccepted,
|
agentToolAccepted: evidence?.agentToolAccepted,
|
||||||
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
|
pendingPermissionRequestIds: evidence?.pendingPermissionRequestIds,
|
||||||
});
|
});
|
||||||
|
|
@ -214,7 +245,7 @@ function createSecondaryLaneMemberState(
|
||||||
laneOwnerProviderId: providerId,
|
laneOwnerProviderId: providerId,
|
||||||
launchState,
|
launchState,
|
||||||
agentToolAccepted: evidence?.agentToolAccepted === true,
|
agentToolAccepted: evidence?.agentToolAccepted === true,
|
||||||
runtimeAlive: evidence?.runtimeAlive === true,
|
runtimeAlive: strongRuntimeAlive,
|
||||||
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
|
bootstrapConfirmed: evidence?.bootstrapConfirmed === true,
|
||||||
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
|
hardFailure: evidence?.hardFailure === true || launchState === 'failed_to_start',
|
||||||
hardFailureReason,
|
hardFailureReason,
|
||||||
|
|
@ -227,15 +258,21 @@ function createSecondaryLaneMemberState(
|
||||||
evidence.runtimePid > 0
|
evidence.runtimePid > 0
|
||||||
? Math.trunc(evidence.runtimePid)
|
? Math.trunc(evidence.runtimePid)
|
||||||
: undefined,
|
: undefined,
|
||||||
|
runtimeSessionId: evidence?.runtimeSessionId ?? evidence?.sessionId,
|
||||||
|
livenessKind: evidence?.livenessKind,
|
||||||
|
pidSource: evidence?.pidSource,
|
||||||
|
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
||||||
|
runtimeDiagnosticSeverity: evidence?.runtimeDiagnosticSeverity,
|
||||||
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
firstSpawnAcceptedAt: evidence?.agentToolAccepted ? params.updatedAt : undefined,
|
||||||
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
lastHeartbeatAt: evidence?.bootstrapConfirmed ? params.updatedAt : undefined,
|
||||||
lastRuntimeAliveAt: evidence?.runtimeAlive ? params.updatedAt : undefined,
|
runtimeLastSeenAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||||
|
lastRuntimeAliveAt: strongRuntimeAlive ? params.updatedAt : undefined,
|
||||||
lastEvaluatedAt: params.updatedAt,
|
lastEvaluatedAt: params.updatedAt,
|
||||||
sources: evidence?.runtimeAlive
|
sources: strongRuntimeAlive
|
||||||
? {
|
? {
|
||||||
processAlive: true,
|
processAlive: true,
|
||||||
nativeHeartbeat: evidence.bootstrapConfirmed === true || undefined,
|
nativeHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
|
||||||
inboxHeartbeat: evidence.bootstrapConfirmed === true || undefined,
|
inboxHeartbeat: evidence?.bootstrapConfirmed === true || undefined,
|
||||||
}
|
}
|
||||||
: undefined,
|
: undefined,
|
||||||
diagnostics: evidence?.diagnostics?.length
|
diagnostics: evidence?.diagnostics?.length
|
||||||
|
|
@ -256,6 +293,11 @@ function summarizeMembers(
|
||||||
let pendingCount = 0;
|
let pendingCount = 0;
|
||||||
let failedCount = 0;
|
let failedCount = 0;
|
||||||
let runtimeAlivePendingCount = 0;
|
let runtimeAlivePendingCount = 0;
|
||||||
|
let shellOnlyPendingCount = 0;
|
||||||
|
let runtimeProcessPendingCount = 0;
|
||||||
|
let runtimeCandidatePendingCount = 0;
|
||||||
|
let noRuntimePendingCount = 0;
|
||||||
|
let permissionPendingCount = 0;
|
||||||
|
|
||||||
for (const memberName of expectedMembers) {
|
for (const memberName of expectedMembers) {
|
||||||
const entry = members[memberName];
|
const entry = members[memberName];
|
||||||
|
|
@ -275,6 +317,22 @@ function summarizeMembers(
|
||||||
if (entry.runtimeAlive) {
|
if (entry.runtimeAlive) {
|
||||||
runtimeAlivePendingCount += 1;
|
runtimeAlivePendingCount += 1;
|
||||||
}
|
}
|
||||||
|
if (entry.launchState === 'runtime_pending_permission') {
|
||||||
|
permissionPendingCount += 1;
|
||||||
|
}
|
||||||
|
if (entry.livenessKind === 'shell_only') {
|
||||||
|
shellOnlyPendingCount += 1;
|
||||||
|
} else if (entry.livenessKind === 'runtime_process') {
|
||||||
|
runtimeProcessPendingCount += 1;
|
||||||
|
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
||||||
|
runtimeCandidatePendingCount += 1;
|
||||||
|
} else if (
|
||||||
|
entry.livenessKind === 'not_found' ||
|
||||||
|
entry.livenessKind === 'stale_metadata' ||
|
||||||
|
entry.livenessKind === 'registered_only'
|
||||||
|
) {
|
||||||
|
noRuntimePendingCount += 1;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -282,6 +340,11 @@ function summarizeMembers(
|
||||||
pendingCount,
|
pendingCount,
|
||||||
failedCount,
|
failedCount,
|
||||||
runtimeAlivePendingCount,
|
runtimeAlivePendingCount,
|
||||||
|
shellOnlyPendingCount,
|
||||||
|
runtimeProcessPendingCount,
|
||||||
|
runtimeCandidatePendingCount,
|
||||||
|
noRuntimePendingCount,
|
||||||
|
permissionPendingCount,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,8 @@
|
||||||
import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter';
|
import { TmuxStatusSourceAdapter } from '../adapters/output/sources/TmuxStatusSourceAdapter';
|
||||||
import {
|
import {
|
||||||
TmuxPlatformCommandExecutor,
|
|
||||||
type RuntimeProcessTableRow,
|
type RuntimeProcessTableRow,
|
||||||
type TmuxPaneRuntimeInfo,
|
type TmuxPaneRuntimeInfo,
|
||||||
|
TmuxPlatformCommandExecutor,
|
||||||
} from '../infrastructure/runtime/TmuxPlatformCommandExecutor';
|
} from '../infrastructure/runtime/TmuxPlatformCommandExecutor';
|
||||||
|
|
||||||
const runtimeStatusSource = new TmuxStatusSourceAdapter();
|
const runtimeStatusSource = new TmuxStatusSourceAdapter();
|
||||||
|
|
|
||||||
|
|
@ -17,3 +17,4 @@ export type {
|
||||||
RuntimeProcessTableRow,
|
RuntimeProcessTableRow,
|
||||||
TmuxPaneRuntimeInfo,
|
TmuxPaneRuntimeInfo,
|
||||||
} from './infrastructure/runtime/TmuxPlatformCommandExecutor';
|
} from './infrastructure/runtime/TmuxPlatformCommandExecutor';
|
||||||
|
export { parseRuntimeProcessTable } from './infrastructure/runtime/TmuxPlatformCommandExecutor';
|
||||||
|
|
|
||||||
|
|
@ -96,4 +96,26 @@ describe('TmuxPlatformCommandExecutor', () => {
|
||||||
3_000
|
3_000
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('lists runtime processes inside WSL on Windows instead of using host ps', async () => {
|
||||||
|
setPlatform('win32');
|
||||||
|
const execInPreferredDistro = vi.fn(async () => ({
|
||||||
|
exitCode: 0,
|
||||||
|
stdout: ' 42 1 opencode runtime --team-name demo\n',
|
||||||
|
stderr: '',
|
||||||
|
}));
|
||||||
|
const executor = new TmuxPlatformCommandExecutor(
|
||||||
|
{
|
||||||
|
execInPreferredDistro,
|
||||||
|
getPersistedPreferredDistroSync: () => 'Ubuntu',
|
||||||
|
} as never,
|
||||||
|
{} as never
|
||||||
|
);
|
||||||
|
|
||||||
|
await expect(executor.listRuntimeProcesses()).resolves.toEqual([
|
||||||
|
{ pid: 42, ppid: 1, command: 'opencode runtime --team-name demo' },
|
||||||
|
]);
|
||||||
|
expect(execInPreferredDistro).toHaveBeenCalledWith(['ps', '-ax', '-o', 'pid=,ppid=,command=']);
|
||||||
|
expect(childProcess.execFile).not.toHaveBeenCalled();
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -17,8 +17,6 @@
|
||||||
process.env.UV_THREADPOOL_SIZE ??= '16';
|
process.env.UV_THREADPOOL_SIZE ??= '16';
|
||||||
|
|
||||||
// Keep userData stable before any integration can initialize Electron storage.
|
// Keep userData stable before any integration can initialize Electron storage.
|
||||||
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
|
||||||
|
|
||||||
// Sentry must stay near the top to capture early errors after storage migration.
|
// Sentry must stay near the top to capture early errors after storage migration.
|
||||||
import './sentry';
|
import './sentry';
|
||||||
|
|
||||||
|
|
@ -142,6 +140,7 @@ import {
|
||||||
markRendererUnavailable,
|
markRendererUnavailable,
|
||||||
safeSendToRenderer,
|
safeSendToRenderer,
|
||||||
} from './utils/safeWebContentsSend';
|
} from './utils/safeWebContentsSend';
|
||||||
|
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
||||||
import { syncTelemetryFlag } from './sentry';
|
import { syncTelemetryFlag } from './sentry';
|
||||||
import {
|
import {
|
||||||
ActiveTeamRegistry,
|
ActiveTeamRegistry,
|
||||||
|
|
|
||||||
|
|
@ -570,6 +570,7 @@ export class ConfigManager {
|
||||||
...DEFAULT_CONFIG.general,
|
...DEFAULT_CONFIG.general,
|
||||||
...(loaded.general ?? {}),
|
...(loaded.general ?? {}),
|
||||||
};
|
};
|
||||||
|
mergedGeneral.multimodelEnabled = true;
|
||||||
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
|
mergedGeneral.claudeRootPath = normalizeConfiguredClaudeRootPath(mergedGeneral.claudeRootPath);
|
||||||
|
|
||||||
// Merge triggers: preserve existing triggers, add missing builtin ones
|
// Merge triggers: preserve existing triggers, add missing builtin ones
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { once } from 'node:events';
|
|
||||||
import readline from 'node:readline';
|
import readline from 'node:readline';
|
||||||
|
|
||||||
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
import { killProcessTree, spawnCli } from '@main/utils/childProcess';
|
||||||
|
|
@ -49,6 +48,9 @@ function withTimeout<T>(promise: Promise<T>, timeoutMs: number, label: string):
|
||||||
|
|
||||||
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
const DEFAULT_REQUEST_TIMEOUT_MS = 3_000;
|
||||||
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
const DEFAULT_TOTAL_TIMEOUT_MS = 8_000;
|
||||||
|
const DEFAULT_STDIN_CLOSE_TIMEOUT_MS = 250;
|
||||||
|
const DEFAULT_CLOSE_TIMEOUT_MS = 1_000;
|
||||||
|
const DEFAULT_FORCE_CLOSE_TIMEOUT_MS = 1_000;
|
||||||
|
|
||||||
export class JsonRpcRequestError extends Error {
|
export class JsonRpcRequestError extends Error {
|
||||||
readonly code: number | null;
|
readonly code: number | null;
|
||||||
|
|
@ -76,6 +78,9 @@ export class JsonRpcStdioClient {
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
totalTimeoutMs?: number;
|
totalTimeoutMs?: number;
|
||||||
|
stdinCloseTimeoutMs?: number;
|
||||||
|
closeTimeoutMs?: number;
|
||||||
|
forceCloseTimeoutMs?: number;
|
||||||
label: string;
|
label: string;
|
||||||
},
|
},
|
||||||
handler: (session: JsonRpcSession) => Promise<T>
|
handler: (session: JsonRpcSession) => Promise<T>
|
||||||
|
|
@ -95,8 +100,14 @@ export class JsonRpcStdioClient {
|
||||||
args: string[];
|
args: string[];
|
||||||
env?: NodeJS.ProcessEnv;
|
env?: NodeJS.ProcessEnv;
|
||||||
requestTimeoutMs?: number;
|
requestTimeoutMs?: number;
|
||||||
|
stdinCloseTimeoutMs?: number;
|
||||||
|
closeTimeoutMs?: number;
|
||||||
|
forceCloseTimeoutMs?: number;
|
||||||
}): Promise<JsonRpcSession> {
|
}): Promise<JsonRpcSession> {
|
||||||
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
const requestTimeoutMs = options.requestTimeoutMs ?? DEFAULT_REQUEST_TIMEOUT_MS;
|
||||||
|
const stdinCloseTimeoutMs = options.stdinCloseTimeoutMs ?? DEFAULT_STDIN_CLOSE_TIMEOUT_MS;
|
||||||
|
const closeTimeoutMs = options.closeTimeoutMs ?? DEFAULT_CLOSE_TIMEOUT_MS;
|
||||||
|
const forceCloseTimeoutMs = options.forceCloseTimeoutMs ?? DEFAULT_FORCE_CLOSE_TIMEOUT_MS;
|
||||||
const child = spawnCli(options.binaryPath, options.args, {
|
const child = spawnCli(options.binaryPath, options.args, {
|
||||||
env: options.env,
|
env: options.env,
|
||||||
stdio: ['pipe', 'pipe', 'pipe'],
|
stdio: ['pipe', 'pipe', 'pipe'],
|
||||||
|
|
@ -194,6 +205,58 @@ export class JsonRpcStdioClient {
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const waitForChildClose = (timeoutMs: number): Promise<boolean> => {
|
||||||
|
if (child.exitCode !== null || child.signalCode !== null) {
|
||||||
|
return Promise.resolve(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new Promise<boolean>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (closedByEvent: boolean): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
child.off('close', onClose);
|
||||||
|
resolve(closedByEvent);
|
||||||
|
};
|
||||||
|
const onClose = (): void => finish(true);
|
||||||
|
const timeoutId = setTimeout(() => finish(false), timeoutMs);
|
||||||
|
timeoutId.unref?.();
|
||||||
|
|
||||||
|
child.once('close', onClose);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const closeStdin = async (): Promise<void> => {
|
||||||
|
if (!child.stdin || child.stdin.destroyed || child.stdin.writableEnded) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
await new Promise<void>((resolve) => {
|
||||||
|
let settled = false;
|
||||||
|
const finish = (): void => {
|
||||||
|
if (settled) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
settled = true;
|
||||||
|
clearTimeout(timeoutId);
|
||||||
|
resolve();
|
||||||
|
};
|
||||||
|
const timeoutId = setTimeout(finish, stdinCloseTimeoutMs);
|
||||||
|
timeoutId.unref?.();
|
||||||
|
|
||||||
|
try {
|
||||||
|
child.stdin!.end(() => finish());
|
||||||
|
} catch {
|
||||||
|
finish();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
const close = async (): Promise<void> => {
|
const close = async (): Promise<void> => {
|
||||||
if (closed) {
|
if (closed) {
|
||||||
return;
|
return;
|
||||||
|
|
@ -204,21 +267,26 @@ export class JsonRpcStdioClient {
|
||||||
notificationListeners.clear();
|
notificationListeners.clear();
|
||||||
lineReader.close();
|
lineReader.close();
|
||||||
|
|
||||||
if (child.stdin && !child.stdin.destroyed && !child.stdin.writableEnded) {
|
await closeStdin();
|
||||||
await new Promise<void>((resolve) => {
|
|
||||||
try {
|
const gracefulClose = waitForChildClose(closeTimeoutMs);
|
||||||
child.stdin!.end(() => resolve());
|
killProcessTree(child, 'SIGTERM');
|
||||||
} catch {
|
if (await gracefulClose) {
|
||||||
resolve();
|
return;
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
killProcessTree(child);
|
this.logger.warn('json-rpc close timed out; force killing process', {
|
||||||
try {
|
pid: child.pid,
|
||||||
await once(child, 'close');
|
timeoutMs: closeTimeoutMs,
|
||||||
} catch {
|
});
|
||||||
this.logger.warn('json-rpc close wait failed');
|
|
||||||
|
const forcedClose = waitForChildClose(forceCloseTimeoutMs);
|
||||||
|
killProcessTree(child, 'SIGKILL');
|
||||||
|
if (!(await forcedClose)) {
|
||||||
|
this.logger.warn('json-rpc force close timed out', {
|
||||||
|
pid: child.pid,
|
||||||
|
timeoutMs: forceCloseTimeoutMs,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,7 @@ import fs from 'node:fs';
|
||||||
import os from 'node:os';
|
import os from 'node:os';
|
||||||
import path from 'node:path';
|
import path from 'node:path';
|
||||||
|
|
||||||
import { afterEach, describe, expect, it } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { JsonRpcStdioClient } from '../JsonRpcStdioClient';
|
import { JsonRpcStdioClient } from '../JsonRpcStdioClient';
|
||||||
|
|
||||||
|
|
@ -42,6 +42,35 @@ rl.on('line', (line) => {
|
||||||
return scriptPath;
|
return scriptPath;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function createSignalIgnoringJsonRpcServerScript(): string {
|
||||||
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), 'json-rpc-stdio-client-'));
|
||||||
|
tempDirs.push(tempDir);
|
||||||
|
const scriptPath = path.join(tempDir, 'server.cjs');
|
||||||
|
fs.writeFileSync(
|
||||||
|
scriptPath,
|
||||||
|
`
|
||||||
|
const readline = require('node:readline');
|
||||||
|
process.on('SIGTERM', () => {});
|
||||||
|
setInterval(() => {}, 10_000);
|
||||||
|
|
||||||
|
const rl = readline.createInterface({ input: process.stdin });
|
||||||
|
rl.on('line', (line) => {
|
||||||
|
const message = JSON.parse(line);
|
||||||
|
if (message.jsonrpc !== '2.0') {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
process.stdout.write(JSON.stringify({
|
||||||
|
jsonrpc: '2.0',
|
||||||
|
id: message.id,
|
||||||
|
result: { ok: true },
|
||||||
|
}) + '\\n');
|
||||||
|
});
|
||||||
|
`,
|
||||||
|
'utf8'
|
||||||
|
);
|
||||||
|
return scriptPath;
|
||||||
|
}
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
for (const dir of tempDirs.splice(0)) {
|
for (const dir of tempDirs.splice(0)) {
|
||||||
fs.rmSync(dir, { recursive: true, force: true });
|
fs.rmSync(dir, { recursive: true, force: true });
|
||||||
|
|
@ -76,4 +105,30 @@ describe('JsonRpcStdioClient', () => {
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('force kills the child when session close does not finish gracefully', async () => {
|
||||||
|
const scriptPath = createSignalIgnoringJsonRpcServerScript();
|
||||||
|
const warn = vi.fn();
|
||||||
|
const client = new JsonRpcStdioClient({ warn });
|
||||||
|
|
||||||
|
await client.withSession(
|
||||||
|
{
|
||||||
|
binaryPath: process.execPath,
|
||||||
|
args: [scriptPath],
|
||||||
|
label: 'stubborn json-rpc close',
|
||||||
|
requestTimeoutMs: 1_000,
|
||||||
|
totalTimeoutMs: 2_000,
|
||||||
|
closeTimeoutMs: 25,
|
||||||
|
forceCloseTimeoutMs: 1_000,
|
||||||
|
},
|
||||||
|
async (session) => {
|
||||||
|
await expect(session.request('ping')).resolves.toEqual({ ok: true });
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(warn).toHaveBeenCalledWith('json-rpc close timed out; force killing process', {
|
||||||
|
pid: expect.any(Number),
|
||||||
|
timeoutMs: 25,
|
||||||
|
});
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -337,10 +337,11 @@ function normalizeBootstrapMemberState(
|
||||||
const status = typeof raw.status === 'string' ? raw.status : 'pending';
|
const status = typeof raw.status === 'string' ? raw.status : 'pending';
|
||||||
const hardFailure = status === 'failed';
|
const hardFailure = status === 'failed';
|
||||||
const bootstrapConfirmed = status === 'bootstrap_confirmed';
|
const bootstrapConfirmed = status === 'bootstrap_confirmed';
|
||||||
const runtimeAlive = bootstrapConfirmed || status === 'runtime_alive';
|
const bootstrapReportedRuntimeAlive = status === 'runtime_alive';
|
||||||
|
const runtimeAlive = bootstrapConfirmed;
|
||||||
const agentToolAccepted =
|
const agentToolAccepted =
|
||||||
bootstrapConfirmed ||
|
bootstrapConfirmed ||
|
||||||
runtimeAlive ||
|
bootstrapReportedRuntimeAlive ||
|
||||||
status === 'registered' ||
|
status === 'registered' ||
|
||||||
status === 'spawn_started' ||
|
status === 'spawn_started' ||
|
||||||
hardFailure;
|
hardFailure;
|
||||||
|
|
@ -351,7 +352,7 @@ function normalizeBootstrapMemberState(
|
||||||
? 'failed_to_start'
|
? 'failed_to_start'
|
||||||
: bootstrapConfirmed
|
: bootstrapConfirmed
|
||||||
? 'confirmed_alive'
|
? 'confirmed_alive'
|
||||||
: runtimeAlive || agentToolAccepted
|
: agentToolAccepted
|
||||||
? 'runtime_pending_bootstrap'
|
? 'runtime_pending_bootstrap'
|
||||||
: 'starting',
|
: 'starting',
|
||||||
agentToolAccepted,
|
agentToolAccepted,
|
||||||
|
|
@ -381,13 +382,13 @@ function normalizeBootstrapMemberState(
|
||||||
? raw.failureReason.trim()
|
? raw.failureReason.trim()
|
||||||
: 'deterministic bootstrap failed',
|
: 'deterministic bootstrap failed',
|
||||||
]
|
]
|
||||||
: runtimeAlive
|
: bootstrapConfirmed
|
||||||
? bootstrapConfirmed
|
? ['late heartbeat received']
|
||||||
? ['late heartbeat received']
|
: bootstrapReportedRuntimeAlive
|
||||||
: ['runtime alive', 'waiting for teammate check-in']
|
? ['runtime alive reported by bootstrap state', 'waiting for strict live verification']
|
||||||
: agentToolAccepted
|
: agentToolAccepted
|
||||||
? ['spawn accepted']
|
? ['spawn accepted']
|
||||||
: undefined,
|
: undefined,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ import { classifyIdleNotificationText } from '@shared/utils/idleNotificationSema
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { createLogger } from '@shared/utils/logger';
|
import { createLogger } from '@shared/utils/logger';
|
||||||
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
import { migrateProviderBackendId } from '@shared/utils/providerBackend';
|
||||||
import { getKanbanColumnFromReviewState, normalizeReviewState } from '@shared/utils/reviewState';
|
import { getKanbanColumnFromReviewState, getReviewStateFromTask } from '@shared/utils/reviewState';
|
||||||
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
import { buildStandaloneSlashCommandMeta } from '@shared/utils/slashCommands';
|
||||||
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
import { formatTaskDisplayLabel } from '@shared/utils/taskIdentity';
|
||||||
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
import { buildTeamMemberColorMap } from '@shared/utils/teamMemberColors';
|
||||||
|
|
@ -265,6 +265,11 @@ function extractPassiveUserPeerSummaryBody(text: string): string | null {
|
||||||
return body.length > 0 ? body : null;
|
return body.length > 0 ? body : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isExplicitLeadRole(role: string | undefined): boolean {
|
||||||
|
const normalized = role?.trim().toLowerCase();
|
||||||
|
return normalized === 'lead' || normalized === 'team lead' || normalized === 'team-lead';
|
||||||
|
}
|
||||||
|
|
||||||
function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
|
function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
|
||||||
return members.some((member) => {
|
return members.some((member) => {
|
||||||
if (isLeadMember(member)) {
|
if (isLeadMember(member)) {
|
||||||
|
|
@ -274,7 +279,7 @@ function hasVisibleLeadMember(members: readonly TeamMemberSnapshot[]): boolean {
|
||||||
if (normalizedName === 'lead') {
|
if (normalizedName === 'lead') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return member.role?.toLowerCase().includes('lead') === true;
|
return isExplicitLeadRole(member.role);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -287,7 +292,7 @@ function hasExplicitLeadInConfig(config: TeamConfig): boolean {
|
||||||
if (normalizedName === 'lead') {
|
if (normalizedName === 'lead') {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
return member.role?.toLowerCase().includes('lead') === true;
|
return isExplicitLeadRole(member.role);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -530,16 +535,22 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveTaskReviewState(
|
private resolveTaskReviewState(
|
||||||
task: Pick<TeamTask, 'reviewState'>
|
task: Pick<TeamTask, 'reviewState' | 'historyEvents' | 'status'>,
|
||||||
|
kanbanTaskState?: KanbanState['tasks'][string]
|
||||||
): 'none' | 'review' | 'needsFix' | 'approved' {
|
): 'none' | 'review' | 'needsFix' | 'approved' {
|
||||||
return normalizeReviewState(task.reviewState);
|
return getReviewStateFromTask({
|
||||||
|
historyEvents: task.historyEvents,
|
||||||
|
reviewState: task.reviewState,
|
||||||
|
status: task.status,
|
||||||
|
kanbanColumn: kanbanTaskState?.column,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private attachKanbanCompatibility(
|
private attachKanbanCompatibility(
|
||||||
task: TeamTask,
|
task: TeamTask,
|
||||||
kanbanTaskState?: KanbanState['tasks'][string]
|
kanbanTaskState?: KanbanState['tasks'][string]
|
||||||
): TeamTaskWithKanban {
|
): TeamTaskWithKanban {
|
||||||
const reviewState = this.resolveTaskReviewState(task);
|
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
||||||
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
const reviewer = this.resolveReviewerFromHistory(task, kanbanTaskState, reviewState) ?? null;
|
||||||
return {
|
return {
|
||||||
...task,
|
...task,
|
||||||
|
|
@ -557,8 +568,15 @@ export class TeamDataService {
|
||||||
private resolveReviewerFromHistory(
|
private resolveReviewerFromHistory(
|
||||||
task: TeamTask,
|
task: TeamTask,
|
||||||
kanbanTaskState?: KanbanState['tasks'][string],
|
kanbanTaskState?: KanbanState['tasks'][string],
|
||||||
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(task)
|
reviewState: 'none' | 'review' | 'needsFix' | 'approved' = this.resolveTaskReviewState(
|
||||||
|
task,
|
||||||
|
kanbanTaskState
|
||||||
|
)
|
||||||
): string | null {
|
): string | null {
|
||||||
|
if (reviewState !== 'review') {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
if (task.historyEvents?.length) {
|
if (task.historyEvents?.length) {
|
||||||
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
|
for (let i = task.historyEvents.length - 1; i >= 0; i--) {
|
||||||
const event = task.historyEvents[i];
|
const event = task.historyEvents[i];
|
||||||
|
|
@ -571,7 +589,10 @@ export class TeamDataService {
|
||||||
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
|
if (event.type === 'review_approved' || event.type === 'review_changes_requested') {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (event.type === 'status_changed' && event.to === 'in_progress') {
|
if (
|
||||||
|
event.type === 'status_changed' &&
|
||||||
|
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')
|
||||||
|
) {
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
if (event.type === 'task_created') {
|
if (event.type === 'task_created') {
|
||||||
|
|
@ -894,7 +915,8 @@ export class TeamDataService {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const info = teamInfoMap.get(task.teamName)!;
|
const info = teamInfoMap.get(task.teamName)!;
|
||||||
const reviewState = this.resolveTaskReviewState(task);
|
const kanbanTaskState = kanbanByTeam.get(task.teamName)?.tasks[task.id];
|
||||||
|
const reviewState = this.resolveTaskReviewState(task, kanbanTaskState);
|
||||||
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
|
const kanbanColumn = getKanbanColumnFromReviewState(reviewState);
|
||||||
|
|
||||||
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
// IPC payload safety: GlobalTask lists can be enormous (especially comments and large nested fields).
|
||||||
|
|
@ -2156,7 +2178,11 @@ export class TeamDataService {
|
||||||
|
|
||||||
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
private resolveLeadNameFromConfig(config: TeamConfig | null): string {
|
||||||
if (!config) return 'team-lead';
|
if (!config) return 'team-lead';
|
||||||
const lead = config.members?.find((m) => m.role?.toLowerCase().includes('lead'));
|
const members = config.members ?? [];
|
||||||
|
const lead =
|
||||||
|
members.find((member) => isLeadMember(member)) ??
|
||||||
|
members.find((member) => member.name?.trim().toLowerCase() === 'lead') ??
|
||||||
|
members.find((member) => isExplicitLeadRole(member.role));
|
||||||
return lead?.name ?? config.members?.[0]?.name ?? 'team-lead';
|
return lead?.name ?? config.members?.[0]?.name ?? 'team-lead';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -2729,9 +2755,9 @@ export class TeamDataService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async requestReview(teamName: string, taskId: string): Promise<void> {
|
async requestReview(teamName: string, taskId: string): Promise<void> {
|
||||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||||
this.getController(teamName).review.requestReview(taskId, {
|
this.getController(teamName).review.requestReview(taskId, {
|
||||||
from: 'user',
|
from: leadName,
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
@ -3194,15 +3220,15 @@ export class TeamDataService {
|
||||||
|
|
||||||
if (patch.op === 'set_column') {
|
if (patch.op === 'set_column') {
|
||||||
if (patch.column === 'review') {
|
if (patch.column === 'review') {
|
||||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||||
controller.review.requestReview(taskId, {
|
controller.review.requestReview(taskId, {
|
||||||
from: 'user',
|
from: leadName,
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||||
controller.review.approveReview(taskId, {
|
controller.review.approveReview(taskId, {
|
||||||
from: 'user',
|
from: leadName,
|
||||||
suppressTaskComment: true,
|
suppressTaskComment: true,
|
||||||
'notify-owner': true,
|
'notify-owner': true,
|
||||||
...(leadSessionId ? { leadSessionId } : {}),
|
...(leadSessionId ? { leadSessionId } : {}),
|
||||||
|
|
@ -3211,9 +3237,9 @@ export class TeamDataService {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const { leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
const { leadName, leadSessionId } = await this.resolveLeadRuntimeContext(teamName);
|
||||||
controller.review.requestChanges(taskId, {
|
controller.review.requestChanges(taskId, {
|
||||||
from: 'user',
|
from: leadName,
|
||||||
comment: patch.comment?.trim() || 'Reviewer requested changes.',
|
comment: patch.comment?.trim() || 'Reviewer requested changes.',
|
||||||
...(patch.op === 'request_changes' && patch.taskRefs?.length
|
...(patch.op === 'request_changes' && patch.taskRefs?.length
|
||||||
? { taskRefs: patch.taskRefs }
|
? { taskRefs: patch.taskRefs }
|
||||||
|
|
|
||||||
|
|
@ -80,6 +80,23 @@ function normalizeLivenessKind(value: unknown): TeamAgentRuntimeLivenessKind | u
|
||||||
: undefined;
|
: 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 {
|
function normalizePidSource(value: unknown): TeamAgentRuntimePidSource | undefined {
|
||||||
return value === 'lead_process' ||
|
return value === 'lead_process' ||
|
||||||
value === 'tmux_pane' ||
|
value === 'tmux_pane' ||
|
||||||
|
|
@ -181,7 +198,7 @@ export function summarizePersistedLaunchMembers(
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
pendingCount += 1;
|
pendingCount += 1;
|
||||||
if (entry.runtimeAlive) {
|
if (preservesStrongRuntimeAlive(entry)) {
|
||||||
runtimeAlivePendingCount += 1;
|
runtimeAlivePendingCount += 1;
|
||||||
}
|
}
|
||||||
if (entry.launchState === 'runtime_pending_permission') {
|
if (entry.launchState === 'runtime_pending_permission') {
|
||||||
|
|
@ -193,7 +210,11 @@ export function summarizePersistedLaunchMembers(
|
||||||
runtimeProcessPendingCount += 1;
|
runtimeProcessPendingCount += 1;
|
||||||
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
||||||
runtimeCandidatePendingCount += 1;
|
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;
|
noRuntimePendingCount += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -369,6 +390,18 @@ function normalizePersistedMemberState(
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
const providerId = normalizeOptionalTeamProviderId(parsed.providerId);
|
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 = {
|
const next: PersistedTeamLaunchMemberState = {
|
||||||
name: normalizedName,
|
name: normalizedName,
|
||||||
providerId,
|
providerId,
|
||||||
|
|
@ -399,8 +432,8 @@ function normalizePersistedMemberState(
|
||||||
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
|
launchIdentity: normalizeLaunchIdentity(parsed.launchIdentity, providerId),
|
||||||
launchState: 'starting',
|
launchState: 'starting',
|
||||||
agentToolAccepted: toBoolean(parsed.agentToolAccepted),
|
agentToolAccepted: toBoolean(parsed.agentToolAccepted),
|
||||||
runtimeAlive: toBoolean(parsed.runtimeAlive),
|
runtimeAlive,
|
||||||
bootstrapConfirmed: toBoolean(parsed.bootstrapConfirmed),
|
bootstrapConfirmed,
|
||||||
hardFailure: toBoolean(parsed.hardFailure),
|
hardFailure: toBoolean(parsed.hardFailure),
|
||||||
hardFailureReason:
|
hardFailureReason:
|
||||||
typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
||||||
|
|
@ -411,7 +444,7 @@ function normalizePersistedMemberState(
|
||||||
),
|
),
|
||||||
runtimePid: normalizeRuntimePid(parsed.runtimePid),
|
runtimePid: normalizeRuntimePid(parsed.runtimePid),
|
||||||
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
|
runtimeSessionId: normalizeOptionalString(parsed.runtimeSessionId),
|
||||||
livenessKind: normalizeLivenessKind(parsed.livenessKind),
|
livenessKind,
|
||||||
pidSource: normalizePidSource(parsed.pidSource),
|
pidSource: normalizePidSource(parsed.pidSource),
|
||||||
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
|
runtimeDiagnostic: normalizeOptionalString(parsed.runtimeDiagnostic),
|
||||||
runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity),
|
runtimeDiagnosticSeverity: normalizeDiagnosticSeverity(parsed.runtimeDiagnosticSeverity),
|
||||||
|
|
@ -424,7 +457,7 @@ function normalizePersistedMemberState(
|
||||||
typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined,
|
typeof parsed.lastRuntimeAliveAt === 'string' ? parsed.lastRuntimeAliveAt : undefined,
|
||||||
lastEvaluatedAt:
|
lastEvaluatedAt:
|
||||||
typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback,
|
typeof parsed.lastEvaluatedAt === 'string' ? parsed.lastEvaluatedAt : updatedAtFallback,
|
||||||
sources: normalizeSources(parsed.sources),
|
sources: Object.values(sources).some(Boolean) ? sources : undefined,
|
||||||
diagnostics: Array.isArray(parsed.diagnostics)
|
diagnostics: Array.isArray(parsed.diagnostics)
|
||||||
? parsed.diagnostics.filter(
|
? parsed.diagnostics.filter(
|
||||||
(item): item is string => typeof item === 'string' && item.trim().length > 0
|
(item): item is string => typeof item === 'string' && item.trim().length > 0
|
||||||
|
|
@ -554,14 +587,15 @@ export function snapshotFromRuntimeMemberStatuses(params: {
|
||||||
sources.nativeHeartbeat = true;
|
sources.nativeHeartbeat = true;
|
||||||
sources.inboxHeartbeat = true;
|
sources.inboxHeartbeat = true;
|
||||||
}
|
}
|
||||||
if (runtime?.livenessSource === 'process' || runtime?.runtimeAlive) {
|
const runtimeAlive = preservesStrongRuntimeAlive(runtime);
|
||||||
|
if (runtime?.livenessSource === 'process' && runtimeAlive) {
|
||||||
sources.processAlive = true;
|
sources.processAlive = true;
|
||||||
}
|
}
|
||||||
const entry: PersistedTeamLaunchMemberState = {
|
const entry: PersistedTeamLaunchMemberState = {
|
||||||
name,
|
name,
|
||||||
launchState: runtime?.launchState ?? 'starting',
|
launchState: runtime?.launchState ?? 'starting',
|
||||||
agentToolAccepted: runtime?.agentToolAccepted === true,
|
agentToolAccepted: runtime?.agentToolAccepted === true,
|
||||||
runtimeAlive: runtime?.runtimeAlive === true,
|
runtimeAlive,
|
||||||
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
bootstrapConfirmed: runtime?.bootstrapConfirmed === true,
|
||||||
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
hardFailure: runtime?.hardFailure === true || runtime?.launchState === 'failed_to_start',
|
||||||
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
hardFailureReason: runtime?.hardFailureReason ?? runtime?.error,
|
||||||
|
|
@ -574,7 +608,7 @@ export function snapshotFromRuntimeMemberStatuses(params: {
|
||||||
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
|
runtimeLastSeenAt: runtime?.livenessLastCheckedAt,
|
||||||
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
firstSpawnAcceptedAt: runtime?.firstSpawnAcceptedAt,
|
||||||
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
lastHeartbeatAt: runtime?.lastHeartbeatAt,
|
||||||
lastRuntimeAliveAt: runtime?.runtimeAlive ? updatedAt : undefined,
|
lastRuntimeAliveAt: runtimeAlive ? updatedAt : undefined,
|
||||||
lastEvaluatedAt: runtime?.updatedAt ?? updatedAt,
|
lastEvaluatedAt: runtime?.updatedAt ?? updatedAt,
|
||||||
sources: Object.values(sources).some(Boolean) ? sources : undefined,
|
sources: Object.values(sources).some(Boolean) ? sources : undefined,
|
||||||
diagnostics: undefined,
|
diagnostics: undefined,
|
||||||
|
|
@ -610,6 +644,7 @@ export function snapshotToMemberSpawnStatuses(
|
||||||
if (!entry) continue;
|
if (!entry) continue;
|
||||||
let status: MemberSpawnStatusEntry['status'] = 'offline';
|
let status: MemberSpawnStatusEntry['status'] = 'offline';
|
||||||
let livenessSource: MemberSpawnLivenessSource | undefined;
|
let livenessSource: MemberSpawnLivenessSource | undefined;
|
||||||
|
const runtimeAlive = preservesStrongRuntimeAlive(entry);
|
||||||
if (entry.launchState === 'failed_to_start') {
|
if (entry.launchState === 'failed_to_start') {
|
||||||
status = 'error';
|
status = 'error';
|
||||||
} else if (entry.launchState === 'confirmed_alive') {
|
} else if (entry.launchState === 'confirmed_alive') {
|
||||||
|
|
@ -619,8 +654,8 @@ export function snapshotToMemberSpawnStatuses(
|
||||||
entry.launchState === 'runtime_pending_permission' ||
|
entry.launchState === 'runtime_pending_permission' ||
|
||||||
entry.launchState === 'runtime_pending_bootstrap'
|
entry.launchState === 'runtime_pending_bootstrap'
|
||||||
) {
|
) {
|
||||||
status = entry.runtimeAlive ? 'online' : 'waiting';
|
status = runtimeAlive ? 'online' : 'waiting';
|
||||||
livenessSource = entry.runtimeAlive ? 'process' : undefined;
|
livenessSource = runtimeAlive ? 'process' : undefined;
|
||||||
} else {
|
} else {
|
||||||
status = entry.agentToolAccepted ? 'waiting' : 'spawning';
|
status = entry.agentToolAccepted ? 'waiting' : 'spawning';
|
||||||
}
|
}
|
||||||
|
|
@ -631,7 +666,7 @@ export function snapshotToMemberSpawnStatuses(
|
||||||
hardFailureReason: entry.hardFailureReason,
|
hardFailureReason: entry.hardFailureReason,
|
||||||
livenessSource,
|
livenessSource,
|
||||||
agentToolAccepted: entry.agentToolAccepted,
|
agentToolAccepted: entry.agentToolAccepted,
|
||||||
runtimeAlive: entry.runtimeAlive,
|
runtimeAlive,
|
||||||
bootstrapConfirmed: entry.bootstrapConfirmed,
|
bootstrapConfirmed: entry.bootstrapConfirmed,
|
||||||
hardFailure: entry.hardFailure,
|
hardFailure: entry.hardFailure,
|
||||||
pendingPermissionRequestIds: entry.pendingPermissionRequestIds,
|
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';
|
} from './TeamLaunchStateEvaluator';
|
||||||
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
import { TeamLaunchStateStore } from './TeamLaunchStateStore';
|
||||||
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
import { TeamMcpConfigBuilder } from './TeamMcpConfigBuilder';
|
||||||
import {
|
|
||||||
isStrongRuntimeEvidence,
|
|
||||||
resolveTeamMemberRuntimeLiveness,
|
|
||||||
} from './TeamRuntimeLivenessResolver';
|
|
||||||
import { isStrictTeamMemberLivenessMode } from './TeamMemberLivenessMode';
|
|
||||||
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
import { TeamMemberLogsFinder } from './TeamMemberLogsFinder';
|
||||||
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
import { TeamMembersMetaStore } from './TeamMembersMetaStore';
|
||||||
import { TeamMetaStore } from './TeamMetaStore';
|
import { TeamMetaStore } from './TeamMetaStore';
|
||||||
|
import {
|
||||||
|
isStrongRuntimeEvidence,
|
||||||
|
resolveTeamMemberRuntimeLiveness,
|
||||||
|
sanitizeProcessCommandForDiagnostics,
|
||||||
|
} from './TeamRuntimeLivenessResolver';
|
||||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||||
import { TeamTaskReader } from './TeamTaskReader';
|
import { TeamTaskReader } from './TeamTaskReader';
|
||||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
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 {
|
function runtimeTaskRefs(teamName: string, value: unknown): InboxMessage['taskRefs'] | undefined {
|
||||||
const refs = normalizeRuntimeStringArray(value);
|
const refs = normalizeRuntimeStringArray(value);
|
||||||
return refs.length > 0
|
return refs.length > 0
|
||||||
|
|
@ -515,6 +563,32 @@ const PREFLIGHT_BINARY_TIMEOUT_MS = 8000;
|
||||||
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
const PREFLIGHT_AUTH_RETRY_DELAY_MS = 2000;
|
||||||
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
const PREFLIGHT_AUTH_MAX_RETRIES = 2;
|
||||||
const OPENCODE_PREFLIGHT_MODEL_PROBE_CONCURRENCY = 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<
|
function applyDistinctProvisioningMemberColors<
|
||||||
T extends { name: string; color?: string; removedAt?: number },
|
T extends { name: string; color?: string; removedAt?: number },
|
||||||
|
|
@ -1537,7 +1611,11 @@ function summarizeMemberSpawnStatusRecord(
|
||||||
runtimeProcessPendingCount += 1;
|
runtimeProcessPendingCount += 1;
|
||||||
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
||||||
runtimeCandidatePendingCount += 1;
|
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;
|
noRuntimePendingCount += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -3245,12 +3323,13 @@ function updateProgress(
|
||||||
function buildLaunchDiagnosticsFromRun(
|
function buildLaunchDiagnosticsFromRun(
|
||||||
run: ProvisioningRun
|
run: ProvisioningRun
|
||||||
): TeamLaunchDiagnosticItem[] | undefined {
|
): TeamLaunchDiagnosticItem[] | undefined {
|
||||||
if (!run.isLaunch || run.memberSpawnStatuses.size === 0) {
|
const memberSpawnStatuses = run.memberSpawnStatuses;
|
||||||
|
if (!run.isLaunch || !memberSpawnStatuses || memberSpawnStatuses.size === 0) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
const observedAt = nowIso();
|
const observedAt = nowIso();
|
||||||
const items: TeamLaunchDiagnosticItem[] = [];
|
const items: TeamLaunchDiagnosticItem[] = [];
|
||||||
for (const [memberName, entry] of run.memberSpawnStatuses.entries()) {
|
for (const [memberName, entry] of memberSpawnStatuses.entries()) {
|
||||||
if (entry.launchState === 'confirmed_alive') {
|
if (entry.launchState === 'confirmed_alive') {
|
||||||
items.push({
|
items.push({
|
||||||
id: `${memberName}:bootstrap_confirmed`,
|
id: `${memberName}:bootstrap_confirmed`,
|
||||||
|
|
@ -3286,6 +3365,18 @@ function buildLaunchDiagnosticsFromRun(
|
||||||
});
|
});
|
||||||
continue;
|
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') {
|
if (entry.livenessKind === 'shell_only') {
|
||||||
items.push({
|
items.push({
|
||||||
id: `${memberName}:tmux_shell_only`,
|
id: `${memberName}:tmux_shell_only`,
|
||||||
|
|
@ -3322,6 +3413,22 @@ function buildLaunchDiagnosticsFromRun(
|
||||||
});
|
});
|
||||||
continue;
|
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) {
|
if (entry.agentToolAccepted) {
|
||||||
items.push({
|
items.push({
|
||||||
id: `${memberName}:spawn_accepted`,
|
id: `${memberName}:spawn_accepted`,
|
||||||
|
|
@ -3679,7 +3786,12 @@ export class TeamProvisioningService {
|
||||||
private readonly runtimeAdapterProgressByRunId = new Map<string, TeamProvisioningProgress>();
|
private readonly runtimeAdapterProgressByRunId = new Map<string, TeamProvisioningProgress>();
|
||||||
private readonly runtimeAdapterRunByTeam = new Map<
|
private readonly runtimeAdapterRunByTeam = new Map<
|
||||||
string,
|
string,
|
||||||
{ runId: string; providerId: TeamProviderId; cwd?: string }
|
{
|
||||||
|
runId: string;
|
||||||
|
providerId: TeamProviderId;
|
||||||
|
cwd?: string;
|
||||||
|
members?: Record<string, TeamRuntimeMemberLaunchEvidence>;
|
||||||
|
}
|
||||||
>();
|
>();
|
||||||
private readonly cancelledRuntimeAdapterRunIds = new Set<string>();
|
private readonly cancelledRuntimeAdapterRunIds = new Set<string>();
|
||||||
private stopAllTeamsGeneration = 0;
|
private stopAllTeamsGeneration = 0;
|
||||||
|
|
@ -5882,7 +5994,10 @@ export class TeamProvisioningService {
|
||||||
},
|
},
|
||||||
diagnostics: mergeRuntimeDiagnostics(
|
diagnostics: mergeRuntimeDiagnostics(
|
||||||
previousMember?.diagnostics,
|
previousMember?.diagnostics,
|
||||||
input.diagnostics,
|
[
|
||||||
|
...normalizeRuntimeStringArray(input.diagnostics),
|
||||||
|
...buildRuntimeToolMetadataDiagnostics(input.metadata),
|
||||||
|
],
|
||||||
input.reason
|
input.reason
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
|
|
@ -6498,17 +6613,13 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
||||||
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
||||||
if (isStrictTeamMemberLivenessMode()) {
|
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
||||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
this.appendMemberBootstrapDiagnostic(
|
||||||
this.appendMemberBootstrapDiagnostic(
|
run,
|
||||||
run,
|
spawnedMemberName,
|
||||||
spawnedMemberName,
|
'already_running requires strong runtime verification'
|
||||||
'already_running requires strong runtime verification'
|
);
|
||||||
);
|
void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
|
||||||
void this.reevaluateMemberLaunchStatus(run, spawnedMemberName);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'online', undefined, 'process');
|
|
||||||
} else {
|
} else {
|
||||||
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
this.setMemberSpawnStatus(run, spawnedMemberName, 'waiting');
|
||||||
}
|
}
|
||||||
|
|
@ -6924,7 +7035,6 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedAt = nowIso();
|
const updatedAt = nowIso();
|
||||||
const strictLiveness = isStrictTeamMemberLivenessMode();
|
|
||||||
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
||||||
const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
|
const persistedTeamMeta = await this.teamMetaStore.getMeta(teamName).catch(() => null);
|
||||||
|
|
||||||
|
|
@ -7066,14 +7176,9 @@ export class TeamProvisioningService {
|
||||||
: isSharedOpenCodeHost
|
: isSharedOpenCodeHost
|
||||||
? false
|
? false
|
||||||
: backendType !== 'in-process';
|
: backendType !== 'in-process';
|
||||||
const launchSnapshotAlive =
|
const historicalBootstrapConfirmed =
|
||||||
this.isTeamAlive(teamName) &&
|
launchMember?.bootstrapConfirmed === true ||
|
||||||
(strictLiveness
|
launchMember?.launchState === 'confirmed_alive';
|
||||||
? launchMember?.bootstrapConfirmed === true ||
|
|
||||||
launchMember?.launchState === 'confirmed_alive'
|
|
||||||
: launchMember?.runtimeAlive === true ||
|
|
||||||
launchMember?.bootstrapConfirmed === true ||
|
|
||||||
launchMember?.launchState === 'confirmed_alive');
|
|
||||||
let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined;
|
let rssBytes = rssPid ? rssBytesByPid.get(rssPid) : undefined;
|
||||||
if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
|
if (rssBytes == null && isSharedOpenCodeHost && typeof rssPid === 'number' && rssPid > 0) {
|
||||||
try {
|
try {
|
||||||
|
|
@ -7089,7 +7194,7 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
snapshotMembers[memberName] = {
|
snapshotMembers[memberName] = {
|
||||||
memberName,
|
memberName,
|
||||||
alive: liveRuntimeMember?.alive === true || launchSnapshotAlive,
|
alive: liveRuntimeMember?.alive === true,
|
||||||
restartable,
|
restartable,
|
||||||
...(backendType ? { backendType } : {}),
|
...(backendType ? { backendType } : {}),
|
||||||
...(memberProviderId ? { providerId: memberProviderId } : {}),
|
...(memberProviderId ? { providerId: memberProviderId } : {}),
|
||||||
|
|
@ -7120,6 +7225,7 @@ export class TeamProvisioningService {
|
||||||
...(liveRuntimeMember?.runtimeLastSeenAt
|
...(liveRuntimeMember?.runtimeLastSeenAt
|
||||||
? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt }
|
? { runtimeLastSeenAt: liveRuntimeMember.runtimeLastSeenAt }
|
||||||
: {}),
|
: {}),
|
||||||
|
...(historicalBootstrapConfirmed ? { historicalBootstrapConfirmed: true } : {}),
|
||||||
...(liveRuntimeMember?.runtimeDiagnostic
|
...(liveRuntimeMember?.runtimeDiagnostic
|
||||||
? { runtimeDiagnostic: liveRuntimeMember.runtimeDiagnostic }
|
? { runtimeDiagnostic: liveRuntimeMember.runtimeDiagnostic }
|
||||||
: {}),
|
: {}),
|
||||||
|
|
@ -7593,8 +7699,7 @@ export class TeamProvisioningService {
|
||||||
if (!refreshed) return;
|
if (!refreshed) return;
|
||||||
if (
|
if (
|
||||||
refreshed.launchState === 'failed_to_start' ||
|
refreshed.launchState === 'failed_to_start' ||
|
||||||
refreshed.launchState === 'confirmed_alive' ||
|
refreshed.launchState === 'confirmed_alive'
|
||||||
refreshed.runtimeAlive
|
|
||||||
) {
|
) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
@ -7602,81 +7707,92 @@ export class TeamProvisioningService {
|
||||||
if (!refreshedFirstSpawnAcceptedAt) {
|
if (!refreshedFirstSpawnAcceptedAt) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (isStrictTeamMemberLivenessMode()) {
|
const restartPending = run.pendingMemberRestarts.has(memberName);
|
||||||
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName);
|
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(run.teamName);
|
||||||
const metadata =
|
const metadata =
|
||||||
runtimeByMember.get(memberName) ??
|
runtimeByMember.get(memberName) ??
|
||||||
[...runtimeByMember.entries()].find(([candidateName]) =>
|
[...runtimeByMember.entries()].find(([candidateName]) =>
|
||||||
matchesObservedMemberNameForExpected(candidateName, memberName)
|
matchesObservedMemberNameForExpected(candidateName, memberName)
|
||||||
)?.[1];
|
)?.[1];
|
||||||
const acceptedAtMs = Date.parse(refreshedFirstSpawnAcceptedAt);
|
const acceptedAtMs = Date.parse(refreshedFirstSpawnAcceptedAt);
|
||||||
const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity;
|
const elapsedMs = Number.isFinite(acceptedAtMs) ? Date.now() - acceptedAtMs : Infinity;
|
||||||
const runtimeDiagnostic = metadata?.runtimeDiagnostic;
|
const runtimeDiagnostic = metadata?.runtimeDiagnostic;
|
||||||
if (metadata?.livenessKind === 'runtime_process') {
|
if (metadata?.livenessKind === 'runtime_process') {
|
||||||
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
|
if (elapsedMs >= MEMBER_BOOTSTRAP_STALL_MS) {
|
||||||
return;
|
run.memberSpawnStatuses.set(memberName, {
|
||||||
}
|
|
||||||
if (metadata?.livenessKind === 'permission_blocked') {
|
|
||||||
const next = {
|
|
||||||
...refreshed,
|
...refreshed,
|
||||||
livenessKind: metadata.livenessKind,
|
livenessKind: metadata.livenessKind,
|
||||||
runtimeDiagnostic: runtimeDiagnostic ?? 'waiting for permission approval',
|
runtimeDiagnostic: 'Runtime process is alive, but no bootstrap check-in after 5 min.',
|
||||||
runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity ?? 'warning',
|
runtimeDiagnosticSeverity: 'warning',
|
||||||
livenessLastCheckedAt: nowIso(),
|
livenessLastCheckedAt: nowIso(),
|
||||||
launchState: 'runtime_pending_permission' as const,
|
});
|
||||||
};
|
|
||||||
run.memberSpawnStatuses.set(memberName, next);
|
|
||||||
this.emitMemberSpawnChange(run, memberName);
|
|
||||||
return;
|
|
||||||
}
|
}
|
||||||
if (
|
this.setMemberSpawnStatus(run, memberName, 'online', undefined, 'process');
|
||||||
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);
|
|
||||||
return;
|
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) {
|
if (restartPending) {
|
||||||
run.pendingMemberRestarts.delete(memberName);
|
run.pendingMemberRestarts.delete(memberName);
|
||||||
}
|
}
|
||||||
this.setMemberSpawnStatus(
|
run.memberSpawnStatuses.set(memberName, {
|
||||||
run,
|
...refreshed,
|
||||||
memberName,
|
runtimeAlive: false,
|
||||||
'error',
|
livenessSource: undefined,
|
||||||
restartPending
|
bootstrapConfirmed: false,
|
||||||
? buildRestartGraceTimeoutReason(memberName)
|
...(metadata?.livenessKind ? { livenessKind: metadata.livenessKind } : {}),
|
||||||
: 'Teammate did not join within the launch grace window.'
|
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||||
);
|
...(metadata?.runtimeDiagnosticSeverity
|
||||||
|
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
|
||||||
|
: {}),
|
||||||
|
livenessLastCheckedAt: nowIso(),
|
||||||
|
});
|
||||||
|
this.setMemberSpawnStatus(run, memberName, 'error', strictReason);
|
||||||
}
|
}
|
||||||
|
|
||||||
private shouldSkipMemberSpawnAudit(run: ProvisioningRun): boolean {
|
private shouldSkipMemberSpawnAudit(run: ProvisioningRun): boolean {
|
||||||
|
|
@ -10152,6 +10268,7 @@ export class TeamProvisioningService {
|
||||||
runId,
|
runId,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
cwd: input.request.cwd,
|
cwd: input.request.cwd,
|
||||||
|
members: result.members,
|
||||||
});
|
});
|
||||||
this.aliveRunByTeam.set(input.request.teamName, runId);
|
this.aliveRunByTeam.set(input.request.teamName, runId);
|
||||||
}
|
}
|
||||||
|
|
@ -11978,7 +12095,6 @@ export class TeamProvisioningService {
|
||||||
async getRuntimeState(teamName: string): Promise<TeamRuntimeState> {
|
async getRuntimeState(teamName: string): Promise<TeamRuntimeState> {
|
||||||
const runId = this.getTrackedRunId(teamName);
|
const runId = this.getTrackedRunId(teamName);
|
||||||
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
||||||
const strictLiveness = isStrictTeamMemberLivenessMode();
|
|
||||||
|
|
||||||
if (!run) {
|
if (!run) {
|
||||||
const recovered = await readBootstrapRuntimeState(teamName);
|
const recovered = await readBootstrapRuntimeState(teamName);
|
||||||
|
|
@ -12221,6 +12337,11 @@ export class TeamProvisioningService {
|
||||||
// Read config.json to get the actual registered members
|
// Read config.json to get the actual registered members
|
||||||
const registeredNames = await this.getRegisteredTeamMemberNames(run.teamName);
|
const registeredNames = await this.getRegisteredTeamMemberNames(run.teamName);
|
||||||
if (!registeredNames) {
|
if (!registeredNames) {
|
||||||
|
try {
|
||||||
|
await fs.promises.access(path.join(getTeamsBasePath(), run.teamName));
|
||||||
|
} catch {
|
||||||
|
return;
|
||||||
|
}
|
||||||
const now = Date.now();
|
const now = Date.now();
|
||||||
if (
|
if (
|
||||||
shouldWarnOnUnreadableMemberAuditConfig({
|
shouldWarnOnUnreadableMemberAuditConfig({
|
||||||
|
|
@ -12351,7 +12472,6 @@ export class TeamProvisioningService {
|
||||||
statuses: Record<string, MemberSpawnStatusEntry>
|
statuses: Record<string, MemberSpawnStatusEntry>
|
||||||
): Promise<Record<string, MemberSpawnStatusEntry>> {
|
): Promise<Record<string, MemberSpawnStatusEntry>> {
|
||||||
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
|
const runtimeByMember = await this.getLiveTeamAgentRuntimeMetadata(teamName);
|
||||||
const strictLiveness = isStrictTeamMemberLivenessMode();
|
|
||||||
const nextStatuses = { ...statuses };
|
const nextStatuses = { ...statuses };
|
||||||
for (const [memberName, metadata] of runtimeByMember.entries()) {
|
for (const [memberName, metadata] of runtimeByMember.entries()) {
|
||||||
const resolvedStatusKey =
|
const resolvedStatusKey =
|
||||||
|
|
@ -12370,20 +12490,23 @@ export class TeamProvisioningService {
|
||||||
if (!current) {
|
if (!current) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const runtimeDiagnostic = buildRuntimeDiagnosticForSpawn(metadata);
|
||||||
const nextEntry: MemberSpawnStatusEntry = {
|
const nextEntry: MemberSpawnStatusEntry = {
|
||||||
...current,
|
...current,
|
||||||
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
...(metadata.model ? { runtimeModel: metadata.model } : {}),
|
||||||
...(metadata.livenessKind ? { livenessKind: metadata.livenessKind } : {}),
|
...(metadata.livenessKind ? { livenessKind: metadata.livenessKind } : {}),
|
||||||
...(metadata.runtimeDiagnostic ? { runtimeDiagnostic: metadata.runtimeDiagnostic } : {}),
|
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||||
...(metadata.runtimeDiagnosticSeverity
|
...(metadata.runtimeDiagnosticSeverity
|
||||||
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
|
? { runtimeDiagnosticSeverity: metadata.runtimeDiagnosticSeverity }
|
||||||
: {}),
|
: {}),
|
||||||
livenessLastCheckedAt: nowIso(),
|
livenessLastCheckedAt: nowIso(),
|
||||||
};
|
};
|
||||||
const failureReason = current.hardFailureReason ?? current.error;
|
const failureReason = current.hardFailureReason ?? current.error;
|
||||||
const hasStrongEvidence = strictLiveness
|
const hasStrongEvidence = isStrongRuntimeEvidence(metadata);
|
||||||
? isStrongRuntimeEvidence(metadata)
|
const hasWeakEvidence =
|
||||||
: metadata.alive === true;
|
metadata.livenessKind != null &&
|
||||||
|
!isStrongRuntimeEvidence(metadata) &&
|
||||||
|
current.bootstrapConfirmed !== true;
|
||||||
if (
|
if (
|
||||||
hasStrongEvidence &&
|
hasStrongEvidence &&
|
||||||
current.hardFailure !== true &&
|
current.hardFailure !== true &&
|
||||||
|
|
@ -12412,6 +12535,26 @@ export class TeamProvisioningService {
|
||||||
nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
|
nextEntry.livenessSource = current.bootstrapConfirmed ? current.livenessSource : 'process';
|
||||||
nextEntry.launchState = deriveMemberLaunchState(nextEntry);
|
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;
|
nextStatuses[resolvedStatusKey] = nextEntry;
|
||||||
}
|
}
|
||||||
return nextStatuses;
|
return nextStatuses;
|
||||||
|
|
@ -12669,7 +12812,6 @@ export class TeamProvisioningService {
|
||||||
if (cached && cached.expiresAtMs > Date.now()) {
|
if (cached && cached.expiresAtMs > Date.now()) {
|
||||||
return cached.metadata;
|
return cached.metadata;
|
||||||
}
|
}
|
||||||
const strictLiveness = isStrictTeamMemberLivenessMode();
|
|
||||||
|
|
||||||
const runId = this.getTrackedRunId(teamName);
|
const runId = this.getTrackedRunId(teamName);
|
||||||
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
const run = runId ? (this.runs.get(runId) ?? null) : null;
|
||||||
|
|
@ -12817,7 +12959,7 @@ export class TeamProvisioningService {
|
||||||
upsertMetadata(memberName, {
|
upsertMetadata(memberName, {
|
||||||
backendType: 'process',
|
backendType: 'process',
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
alive: evidence?.runtimeAlive === true,
|
alive: false,
|
||||||
livenessKind: evidence?.livenessKind,
|
livenessKind: evidence?.livenessKind,
|
||||||
pidSource: evidence?.pidSource,
|
pidSource: evidence?.pidSource,
|
||||||
runtimeDiagnostic: evidence?.runtimeDiagnostic,
|
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);
|
const persistedLaunchSnapshot = await this.launchStateStore.read(teamName).catch(() => null);
|
||||||
for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) {
|
for (const persistedMember of Object.values(persistedLaunchSnapshot?.members ?? {})) {
|
||||||
const memberName = persistedMember.name?.trim() ?? '';
|
const memberName = persistedMember.name?.trim() ?? '';
|
||||||
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
|
if (!memberName || this.isMemberRemovedInMeta(metaMembers, memberName)) {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
const currentRuntimeAdapterEvidence = currentRuntimeAdapterRun?.members?.[memberName];
|
||||||
upsertMetadata(memberName, {
|
upsertMetadata(memberName, {
|
||||||
backendType:
|
backendType:
|
||||||
persistedMember.providerId === 'opencode'
|
persistedMember.providerId === 'opencode'
|
||||||
? 'process'
|
? 'process'
|
||||||
: metadataByMember.get(memberName)?.backendType,
|
: metadataByMember.get(memberName)?.backendType,
|
||||||
providerId: persistedMember.providerId,
|
providerId: persistedMember.providerId,
|
||||||
alive: persistedMember.runtimeAlive === true || persistedMember.bootstrapConfirmed === true,
|
alive: false,
|
||||||
livenessKind: persistedMember.livenessKind,
|
livenessKind: currentRuntimeAdapterEvidence?.livenessKind ?? persistedMember.livenessKind,
|
||||||
pidSource: persistedMember.pidSource,
|
pidSource: currentRuntimeAdapterEvidence?.pidSource ?? persistedMember.pidSource,
|
||||||
runtimeDiagnostic: persistedMember.runtimeDiagnostic,
|
runtimeDiagnostic:
|
||||||
|
currentRuntimeAdapterEvidence?.runtimeDiagnostic ?? persistedMember.runtimeDiagnostic,
|
||||||
runtimeDiagnosticSeverity: persistedMember.runtimeDiagnosticSeverity,
|
runtimeDiagnosticSeverity: persistedMember.runtimeDiagnosticSeverity,
|
||||||
runtimeLastSeenAt:
|
runtimeLastSeenAt:
|
||||||
persistedMember.runtimeLastSeenAt ??
|
persistedMember.runtimeLastSeenAt ??
|
||||||
persistedMember.lastHeartbeatAt ??
|
persistedMember.lastHeartbeatAt ??
|
||||||
persistedMember.lastRuntimeAliveAt,
|
persistedMember.lastRuntimeAliveAt,
|
||||||
...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}),
|
...(persistedMember.model?.trim() ? { model: persistedMember.model.trim() } : {}),
|
||||||
...(typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0
|
...(typeof currentRuntimeAdapterEvidence?.runtimePid === 'number' &&
|
||||||
? { metricsPid: persistedMember.runtimePid }
|
currentRuntimeAdapterEvidence.runtimePid > 0
|
||||||
: {}),
|
? { metricsPid: currentRuntimeAdapterEvidence.runtimePid }
|
||||||
...(persistedMember.runtimeSessionId
|
: typeof persistedMember.runtimePid === 'number' && persistedMember.runtimePid > 0
|
||||||
? { runtimeSessionId: persistedMember.runtimeSessionId }
|
? { 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()) {
|
for (const [memberName, metadata] of metadataByMember.entries()) {
|
||||||
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
const paneId = metadata.tmuxPaneId?.trim() ?? '';
|
||||||
const status = this.findTrackedMemberSpawnStatus(run, memberName);
|
|
||||||
const launchMember = persistedLaunchSnapshot?.members[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({
|
const resolved = resolveTeamMemberRuntimeLiveness({
|
||||||
teamName,
|
teamName,
|
||||||
memberName,
|
memberName,
|
||||||
|
|
@ -12910,15 +13089,9 @@ export class TeamProvisioningService {
|
||||||
processTableAvailable,
|
processTableAvailable,
|
||||||
nowIso: nowIso(),
|
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, {
|
metadataByMember.set(memberName, {
|
||||||
...metadata,
|
...metadata,
|
||||||
alive: strictLiveness ? resolved.alive : legacyWeakAlive,
|
alive: resolved.alive,
|
||||||
...(typeof resolved.pid === 'number' && resolved.pid > 0 ? { pid: resolved.pid } : {}),
|
...(typeof resolved.pid === 'number' && resolved.pid > 0 ? { pid: resolved.pid } : {}),
|
||||||
...(typeof (resolved.metricsPid ?? metadata.metricsPid) === 'number' &&
|
...(typeof (resolved.metricsPid ?? metadata.metricsPid) === 'number' &&
|
||||||
Number.isFinite(resolved.metricsPid ?? metadata.metricsPid) &&
|
Number.isFinite(resolved.metricsPid ?? metadata.metricsPid) &&
|
||||||
|
|
@ -13050,7 +13223,11 @@ export class TeamProvisioningService {
|
||||||
runtimeProcessPendingCount += 1;
|
runtimeProcessPendingCount += 1;
|
||||||
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
} else if (entry.livenessKind === 'runtime_process_candidate') {
|
||||||
runtimeCandidatePendingCount += 1;
|
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;
|
noRuntimePendingCount += 1;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -13100,10 +13277,8 @@ export class TeamProvisioningService {
|
||||||
}`;
|
}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
const stillStartingCount = Math.max(
|
const runtimeProcessPendingCount = launchSummary.runtimeProcessPendingCount ?? 0;
|
||||||
0,
|
const stillStartingCount = Math.max(0, launchSummary.pendingCount - runtimeProcessPendingCount);
|
||||||
launchSummary.pendingCount - launchSummary.runtimeAlivePendingCount
|
|
||||||
);
|
|
||||||
const diagnosticParts = [
|
const diagnosticParts = [
|
||||||
launchSummary.shellOnlyPendingCount
|
launchSummary.shellOnlyPendingCount
|
||||||
? `${launchSummary.shellOnlyPendingCount} shell-only`
|
? `${launchSummary.shellOnlyPendingCount} shell-only`
|
||||||
|
|
@ -13121,16 +13296,15 @@ export class TeamProvisioningService {
|
||||||
const diagnosticSuffix = diagnosticParts.length > 0 ? ` - ${diagnosticParts.join(', ')}` : '';
|
const diagnosticSuffix = diagnosticParts.length > 0 ? ` - ${diagnosticParts.join(', ')}` : '';
|
||||||
if (launchSummary.confirmedCount === 0) {
|
if (launchSummary.confirmedCount === 0) {
|
||||||
const allRuntimeAlive =
|
const allRuntimeAlive =
|
||||||
launchSummary.runtimeAlivePendingCount > 0 &&
|
runtimeProcessPendingCount > 0 && runtimeProcessPendingCount === expectedTeammateCount;
|
||||||
launchSummary.runtimeAlivePendingCount === expectedTeammateCount;
|
|
||||||
return allRuntimeAlive
|
return allRuntimeAlive
|
||||||
? `${prefix} — teammates online`
|
? `${prefix} — teammates online`
|
||||||
: launchSummary.runtimeAlivePendingCount > 0
|
: runtimeProcessPendingCount > 0
|
||||||
? `${prefix} — ${launchSummary.runtimeAlivePendingCount}/${expectedTeammateCount} teammate${launchSummary.runtimeAlivePendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}`
|
? `${prefix} — ${runtimeProcessPendingCount}/${expectedTeammateCount} teammate${runtimeProcessPendingCount === 1 ? '' : 's'} online${stillStartingCount > 0 ? `, ${stillStartingCount} still starting` : ''}`
|
||||||
: `${prefix} — teammates are still starting${diagnosticSuffix}`;
|
: `${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(
|
private buildAggregatePendingLaunchMessage(
|
||||||
|
|
@ -13141,6 +13315,7 @@ export class TeamProvisioningService {
|
||||||
pendingCount: number;
|
pendingCount: number;
|
||||||
failedCount: number;
|
failedCount: number;
|
||||||
runtimeAlivePendingCount: number;
|
runtimeAlivePendingCount: number;
|
||||||
|
runtimeProcessPendingCount?: number;
|
||||||
},
|
},
|
||||||
snapshot?: PersistedTeamLaunchSnapshot | null
|
snapshot?: PersistedTeamLaunchSnapshot | null
|
||||||
): string {
|
): string {
|
||||||
|
|
@ -13554,10 +13729,14 @@ export class TeamProvisioningService {
|
||||||
|
|
||||||
if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') {
|
if (filteredSnapshot.teamLaunchState === 'clean_success' && launchPhase !== 'active') {
|
||||||
await this.clearPersistedLaunchState(run.teamName);
|
await this.clearPersistedLaunchState(run.teamName);
|
||||||
|
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
||||||
|
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.launchStateStore.write(run.teamName, filteredSnapshot);
|
await this.launchStateStore.write(run.teamName, filteredSnapshot);
|
||||||
|
this.agentRuntimeSnapshotCache.delete(run.teamName);
|
||||||
|
this.liveTeamAgentRuntimeMetadataCache.delete(run.teamName);
|
||||||
return filteredSnapshot;
|
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 nextMembers = { ...filteredPersisted.members };
|
||||||
const persistedMemberNames = this.getPersistedLaunchMemberNames(filteredPersisted);
|
const persistedMemberNames = this.getPersistedLaunchMemberNames(filteredPersisted);
|
||||||
const now = nowIso();
|
const now = nowIso();
|
||||||
|
|
@ -14262,11 +14441,6 @@ export class TeamProvisioningService {
|
||||||
current.firstSpawnAcceptedAt =
|
current.firstSpawnAcceptedAt =
|
||||||
current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt;
|
current.firstSpawnAcceptedAt ?? bootstrapMember.firstSpawnAcceptedAt;
|
||||||
}
|
}
|
||||||
if (bootstrapMember?.runtimeAlive && !current.runtimeAlive) {
|
|
||||||
current.runtimeAlive = true;
|
|
||||||
current.lastRuntimeAliveAt =
|
|
||||||
current.lastRuntimeAliveAt ?? bootstrapMember.lastRuntimeAliveAt;
|
|
||||||
}
|
|
||||||
if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) {
|
if (bootstrapMember?.bootstrapConfirmed && !current.bootstrapConfirmed) {
|
||||||
current.bootstrapConfirmed = true;
|
current.bootstrapConfirmed = true;
|
||||||
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt;
|
current.lastHeartbeatAt = current.lastHeartbeatAt ?? bootstrapMember.lastHeartbeatAt;
|
||||||
|
|
@ -14274,10 +14448,13 @@ export class TeamProvisioningService {
|
||||||
const matchedConfigNames = [...configMembers].filter((name) =>
|
const matchedConfigNames = [...configMembers].filter((name) =>
|
||||||
matchesObservedMemberNameForExpected(name, expected)
|
matchesObservedMemberNameForExpected(name, expected)
|
||||||
);
|
);
|
||||||
const observedRuntimeAlive = [...liveAgentNames].some((name) =>
|
const runtimeMetadataCandidates = [...liveRuntimeByMember.entries()].filter(([name]) =>
|
||||||
matchesObservedMemberNameForExpected(name, expected)
|
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(
|
const heartbeatMessage = this.selectLatestLeadInboxLaunchReconcileMessage(
|
||||||
leadInboxMessages,
|
leadInboxMessages,
|
||||||
persistedMemberNames,
|
persistedMemberNames,
|
||||||
|
|
@ -14289,11 +14466,15 @@ export class TeamProvisioningService {
|
||||||
: null;
|
: null;
|
||||||
const acceptedAtMs =
|
const acceptedAtMs =
|
||||||
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
current.firstSpawnAcceptedAt != null ? Date.parse(current.firstSpawnAcceptedAt) : NaN;
|
||||||
current.runtimeAlive = runtimeAlive;
|
current.runtimeAlive = observedRuntimeAlive;
|
||||||
current.lastRuntimeAliveAt = observedRuntimeAlive ? now : current.lastRuntimeAliveAt;
|
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 = {
|
||||||
...(current.sources ?? {}),
|
...(current.sources ?? {}),
|
||||||
processAlive: runtimeAlive || undefined,
|
processAlive: observedRuntimeAlive || undefined,
|
||||||
configRegistered: matchedConfigNames.length > 0 || undefined,
|
configRegistered: matchedConfigNames.length > 0 || undefined,
|
||||||
configDrift:
|
configDrift:
|
||||||
heartbeatMessage != null && matchedConfigNames.length === 0
|
heartbeatMessage != null && matchedConfigNames.length === 0
|
||||||
|
|
@ -15050,13 +15231,14 @@ export class TeamProvisioningService {
|
||||||
run.processKilled = true;
|
run.processKilled = true;
|
||||||
run.cancelRequested = true;
|
run.cancelRequested = true;
|
||||||
killTeamProcess(run.child);
|
killTeamProcess(run.child);
|
||||||
if (this.hasSecondaryRuntimeRuns(teamName)) {
|
const stopSecondaryRuntimeLanes = this.hasSecondaryRuntimeRuns(teamName)
|
||||||
await this.stopMixedSecondaryRuntimeLanes(teamName);
|
? this.stopMixedSecondaryRuntimeLanes(teamName)
|
||||||
}
|
: null;
|
||||||
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
|
const progress = updateProgress(run, 'disconnected', 'Team stopped by user');
|
||||||
run.onProgress(progress);
|
run.onProgress(progress);
|
||||||
this.cleanupRun(run);
|
this.cleanupRun(run);
|
||||||
logger.info(`[${teamName}] Process stopped (SIGKILL)`);
|
logger.info(`[${teamName}] Process stopped (SIGKILL)`);
|
||||||
|
await stopSecondaryRuntimeLanes;
|
||||||
}
|
}
|
||||||
|
|
||||||
private getShutdownTrackedTeamNames(): string[] {
|
private getShutdownTrackedTeamNames(): string[] {
|
||||||
|
|
@ -15448,8 +15630,8 @@ export class TeamProvisioningService {
|
||||||
killTrackedCliProcesses('SIGKILL');
|
killTrackedCliProcesses('SIGKILL');
|
||||||
this.killTransientProbeProcessesForShutdown();
|
this.killTransientProbeProcessesForShutdown();
|
||||||
|
|
||||||
await this.cancelPendingRuntimeAdapterLaunchesForShutdown();
|
|
||||||
const initialTracked = await this.stopTrackedTeamsForShutdown('Shutdown');
|
const initialTracked = await this.stopTrackedTeamsForShutdown('Shutdown');
|
||||||
|
await this.cancelPendingRuntimeAdapterLaunchesForShutdown();
|
||||||
|
|
||||||
// A create/launch may have been inside a per-team lock before it exposed a
|
// 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
|
// run in provisioningRunByTeam. Wait briefly, then rescan to catch anything
|
||||||
|
|
@ -15584,7 +15766,15 @@ export class TeamProvisioningService {
|
||||||
);
|
);
|
||||||
return true;
|
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;
|
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 {
|
function hasPersistedEvidence(input: ResolveTeamMemberRuntimeLivenessInput): boolean {
|
||||||
return Boolean(
|
return Boolean(
|
||||||
input.agentId?.trim() ||
|
input.agentId?.trim() ||
|
||||||
|
|
@ -186,18 +190,6 @@ export function resolveTeamMemberRuntimeLiveness(
|
||||||
diagnostics.push('process table unavailable');
|
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 (
|
if (
|
||||||
tracked?.launchState === 'runtime_pending_permission' ||
|
tracked?.launchState === 'runtime_pending_permission' ||
|
||||||
(tracked?.pendingPermissionRequestIds?.length ?? 0) > 0
|
(tracked?.pendingPermissionRequestIds?.length ?? 0) > 0
|
||||||
|
|
@ -236,15 +228,44 @@ export function resolveTeamMemberRuntimeLiveness(
|
||||||
? input.processRows.find((row) => row.pid === runtimePid)
|
? input.processRows.find((row) => row.pid === runtimePid)
|
||||||
: undefined;
|
: undefined;
|
||||||
if (runtimePidRow && input.providerId === 'opencode') {
|
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({
|
return result({
|
||||||
alive: true,
|
alive: false,
|
||||||
livenessKind: 'runtime_process',
|
livenessKind: 'runtime_process_candidate',
|
||||||
pidSource: 'opencode_bridge',
|
pidSource: 'opencode_bridge',
|
||||||
pid: runtimePidRow.pid,
|
pid: runtimePidRow.pid,
|
||||||
runtimeSessionId,
|
runtimeSessionId,
|
||||||
processCommand: sanitizeProcessCommandForDiagnostics(runtimePidRow.command),
|
processCommand,
|
||||||
runtimeDiagnostic: 'OpenCode runtime process detected',
|
runtimeDiagnostic: 'OpenCode runtime pid is alive, but process identity is unverified',
|
||||||
diagnostics: [...diagnostics, 'matched OpenCode runtime pid in process table'],
|
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 (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({
|
return result({
|
||||||
alive: false,
|
alive: false,
|
||||||
livenessKind: 'stale_metadata',
|
livenessKind: 'stale_metadata',
|
||||||
|
|
|
||||||
|
|
@ -170,6 +170,11 @@ export class TeamTaskReader {
|
||||||
completedAt: i.completedAt,
|
completedAt: i.completedAt,
|
||||||
}))
|
}))
|
||||||
: undefined;
|
: undefined;
|
||||||
|
const status = (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
||||||
|
parsed.status as TeamTask['status']
|
||||||
|
)
|
||||||
|
? (parsed.status as TeamTask['status'])
|
||||||
|
: 'pending';
|
||||||
const task: TeamTask = {
|
const task: TeamTask = {
|
||||||
id:
|
id:
|
||||||
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||||
|
|
@ -192,11 +197,7 @@ export class TeamTaskReader {
|
||||||
promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs),
|
promptTaskRefs: normalizeTaskRefs(parsed.promptTaskRefs),
|
||||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||||
status: (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
status,
|
||||||
parsed.status as TeamTask['status']
|
|
||||||
)
|
|
||||||
? (parsed.status as TeamTask['status'])
|
|
||||||
: 'pending',
|
|
||||||
workIntervals,
|
workIntervals,
|
||||||
historyEvents,
|
historyEvents,
|
||||||
blocks: Array.isArray(parsed.blocks)
|
blocks: Array.isArray(parsed.blocks)
|
||||||
|
|
@ -299,6 +300,7 @@ export class TeamTaskReader {
|
||||||
reviewState: getReviewStateFromTask({
|
reviewState: getReviewStateFromTask({
|
||||||
historyEvents,
|
historyEvents,
|
||||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||||
|
status,
|
||||||
}),
|
}),
|
||||||
sourceMessageId:
|
sourceMessageId:
|
||||||
typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim()
|
typeof parsed.sourceMessageId === 'string' && parsed.sourceMessageId.trim()
|
||||||
|
|
@ -413,6 +415,7 @@ export class TeamTaskReader {
|
||||||
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
createdAt: typeof parsed.createdAt === 'string' ? parsed.createdAt : undefined,
|
||||||
reviewState: getReviewStateFromTask({
|
reviewState: getReviewStateFromTask({
|
||||||
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
reviewState: parsed.reviewState as TeamTask['reviewState'],
|
||||||
|
status: 'deleted',
|
||||||
}),
|
}),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,3 @@
|
||||||
import { configManager } from '../infrastructure/ConfigManager';
|
|
||||||
|
|
||||||
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
|
import type { CliFlavor, CliFlavorUiOptions } from '@shared/types';
|
||||||
|
|
||||||
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator';
|
export const DEFAULT_CLI_FLAVOR: CliFlavor = 'agent_teams_orchestrator';
|
||||||
|
|
@ -18,8 +16,7 @@ export function getConfiguredCliFlavor(): CliFlavor {
|
||||||
return envOverride;
|
return envOverride;
|
||||||
}
|
}
|
||||||
|
|
||||||
const multimodelEnabled = configManager.getConfig().general.multimodelEnabled;
|
return DEFAULT_CLI_FLAVOR;
|
||||||
return multimodelEnabled ? 'agent_teams_orchestrator' : 'claude';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
|
export function getCliFlavorUiOptions(flavor: CliFlavor): CliFlavorUiOptions {
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,8 @@ export const PROGRESS_LOG_TAIL_LINES = 200;
|
||||||
export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
|
export const PROGRESS_OUTPUT_TAIL_PARTS = 20;
|
||||||
export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20;
|
export const PROGRESS_LAUNCH_DIAGNOSTICS_LIMIT = 20;
|
||||||
const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500;
|
const PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT = 500;
|
||||||
|
const SECRET_FLAG_PATTERN =
|
||||||
|
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n"
|
* Return the trailing `maxLines` of a line-buffered CLI log, joined with "\n"
|
||||||
|
|
@ -60,9 +62,10 @@ function boundDiagnosticText(value: string | undefined): string | undefined {
|
||||||
if (!trimmed) {
|
if (!trimmed) {
|
||||||
return undefined;
|
return undefined;
|
||||||
}
|
}
|
||||||
return trimmed.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT
|
const redacted = trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]');
|
||||||
? `${trimmed.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...`
|
return redacted.length > PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT
|
||||||
: trimmed;
|
? `${redacted.slice(0, PROGRESS_LAUNCH_DIAGNOSTIC_TEXT_LIMIT - 3).trimEnd()}...`
|
||||||
|
: redacted;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function boundLaunchDiagnostics(
|
export function boundLaunchDiagnostics(
|
||||||
|
|
|
||||||
|
|
@ -302,7 +302,7 @@ export class OpenCodeTeamRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
providerId: this.providerId,
|
providerId: this.providerId,
|
||||||
launchState: member.launchState,
|
launchState: member.launchState,
|
||||||
agentToolAccepted: member.agentToolAccepted,
|
agentToolAccepted: member.agentToolAccepted,
|
||||||
runtimeAlive: member.runtimeAlive,
|
runtimeAlive: member.bootstrapConfirmed === true,
|
||||||
bootstrapConfirmed: member.bootstrapConfirmed,
|
bootstrapConfirmed: member.bootstrapConfirmed,
|
||||||
hardFailure: member.hardFailure,
|
hardFailure: member.hardFailure,
|
||||||
hardFailureReason: member.hardFailureReason,
|
hardFailureReason: member.hardFailureReason,
|
||||||
|
|
@ -544,7 +544,6 @@ function mapBridgeMemberToRuntimeEvidence(
|
||||||
diagnostics: string[]
|
diagnostics: string[]
|
||||||
): TeamRuntimeMemberLaunchEvidence {
|
): TeamRuntimeMemberLaunchEvidence {
|
||||||
const confirmed = launchState === 'confirmed_alive';
|
const confirmed = launchState === 'confirmed_alive';
|
||||||
const createdOrBlocked = launchState === 'created' || launchState === 'permission_blocked';
|
|
||||||
const failed = launchState === 'failed';
|
const failed = launchState === 'failed';
|
||||||
const hasRuntimePid =
|
const hasRuntimePid =
|
||||||
typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0;
|
typeof runtimePid === 'number' && Number.isFinite(runtimePid) && runtimePid > 0;
|
||||||
|
|
@ -552,14 +551,14 @@ function mapBridgeMemberToRuntimeEvidence(
|
||||||
const livenessKind = confirmed
|
const livenessKind = confirmed
|
||||||
? 'confirmed_bootstrap'
|
? 'confirmed_bootstrap'
|
||||||
: pendingRuntimeObserved
|
: pendingRuntimeObserved
|
||||||
? 'runtime_process'
|
? 'runtime_process_candidate'
|
||||||
: launchState === 'permission_blocked'
|
: launchState === 'permission_blocked'
|
||||||
? 'permission_blocked'
|
? 'permission_blocked'
|
||||||
: runtimeMaterialized || sessionId
|
: runtimeMaterialized || sessionId
|
||||||
? 'runtime_process_candidate'
|
? 'runtime_process_candidate'
|
||||||
: 'registered_only';
|
: 'registered_only';
|
||||||
const runtimeDiagnostic = pendingRuntimeObserved
|
const runtimeDiagnostic = pendingRuntimeObserved
|
||||||
? 'OpenCode runtime process reported by bridge'
|
? 'OpenCode runtime pid reported by bridge without local process verification'
|
||||||
: launchState === 'permission_blocked'
|
: launchState === 'permission_blocked'
|
||||||
? 'OpenCode runtime is waiting for permission approval'
|
? 'OpenCode runtime is waiting for permission approval'
|
||||||
: runtimeMaterialized || sessionId
|
: runtimeMaterialized || sessionId
|
||||||
|
|
@ -575,8 +574,13 @@ function mapBridgeMemberToRuntimeEvidence(
|
||||||
: launchState === 'permission_blocked'
|
: launchState === 'permission_blocked'
|
||||||
? 'runtime_pending_permission'
|
? 'runtime_pending_permission'
|
||||||
: 'runtime_pending_bootstrap',
|
: 'runtime_pending_bootstrap',
|
||||||
agentToolAccepted: confirmed || createdOrBlocked || runtimeMaterialized,
|
agentToolAccepted:
|
||||||
runtimeAlive: confirmed || pendingRuntimeObserved,
|
confirmed ||
|
||||||
|
pendingRuntimeObserved ||
|
||||||
|
launchState === 'permission_blocked' ||
|
||||||
|
runtimeMaterialized ||
|
||||||
|
Boolean(sessionId),
|
||||||
|
runtimeAlive: confirmed,
|
||||||
bootstrapConfirmed: confirmed,
|
bootstrapConfirmed: confirmed,
|
||||||
hardFailure: failed,
|
hardFailure: failed,
|
||||||
hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined,
|
hardFailureReason: failed ? 'OpenCode bridge reported member launch failure' : undefined,
|
||||||
|
|
|
||||||
|
|
@ -31,6 +31,10 @@ export function encodePath(absolutePath: string): string {
|
||||||
}
|
}
|
||||||
|
|
||||||
const encoded = absolutePath.replace(/[/\\]/g, '-');
|
const encoded = absolutePath.replace(/[/\\]/g, '-');
|
||||||
|
const windowsDriveMatch = /^([a-zA-Z]):-(.*)$/.exec(encoded);
|
||||||
|
if (windowsDriveMatch) {
|
||||||
|
return `${windowsDriveMatch[1].toUpperCase()}--${windowsDriveMatch[2]}`;
|
||||||
|
}
|
||||||
|
|
||||||
// Ensure leading dash for absolute paths
|
// Ensure leading dash for absolute paths
|
||||||
return encoded.startsWith('-') ? encoded : `-${encoded}`;
|
return encoded.startsWith('-') ? encoded : `-${encoded}`;
|
||||||
|
|
@ -50,7 +54,7 @@ export function decodePath(encodedName: string): string {
|
||||||
|
|
||||||
// Legacy Windows format observed in some Claude installs: "C--Users-name-project"
|
// Legacy Windows format observed in some Claude installs: "C--Users-name-project"
|
||||||
// (no leading dash, drive separator encoded as "--").
|
// (no leading dash, drive separator encoded as "--").
|
||||||
const legacyWindowsRegex = /^([a-zA-Z])--(.+)$/;
|
const legacyWindowsRegex = /^([a-zA-Z])--(.*)$/;
|
||||||
const legacyWindowsMatch = legacyWindowsRegex.exec(encodedName);
|
const legacyWindowsMatch = legacyWindowsRegex.exec(encodedName);
|
||||||
if (legacyWindowsMatch) {
|
if (legacyWindowsMatch) {
|
||||||
const drive = legacyWindowsMatch[1].toUpperCase();
|
const drive = legacyWindowsMatch[1].toUpperCase();
|
||||||
|
|
|
||||||
|
|
@ -97,6 +97,13 @@ interface TaskReadDiag {
|
||||||
|
|
||||||
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
const MAX_LAUNCH_STATE_BYTES = 32 * 1024;
|
||||||
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
const TEAM_LAUNCH_STATE_FILE = 'launch-state.json';
|
||||||
|
const REVIEW_LIFECYCLE_EVENTS = new Set([
|
||||||
|
'review_requested',
|
||||||
|
'review_changes_requested',
|
||||||
|
'review_approved',
|
||||||
|
'review_started',
|
||||||
|
]);
|
||||||
|
const REVIEW_RESET_STATUSES = new Set(['in_progress', 'deleted']);
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Parsed JSON types (loose shapes from disk)
|
// Parsed JSON types (loose shapes from disk)
|
||||||
|
|
@ -743,28 +750,66 @@ function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefin
|
||||||
.map((i) => ({ ...i }));
|
.map((i) => ({ ...i }));
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Derive review state from historyEvents (inline reducer for worker isolation). */
|
function normalizeReviewState(value: unknown): string {
|
||||||
function deriveReviewStateFromEvents(events: RawHistoryEvent[] | undefined): string {
|
return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none';
|
||||||
if (!Array.isArray(events) || events.length === 0) return 'none';
|
}
|
||||||
for (let i = events.length - 1; i >= 0; i--) {
|
|
||||||
const e = events[i];
|
function normalizeFallbackReviewState(value: unknown, status: string): string {
|
||||||
const t = e.type;
|
const reviewState = normalizeReviewState(value);
|
||||||
if (
|
if (reviewState === 'none') return 'none';
|
||||||
t === 'review_requested' ||
|
if (status === 'in_progress' || status === 'deleted') return 'none';
|
||||||
t === 'review_changes_requested' ||
|
if (status === 'pending') return reviewState === 'needsFix' ? 'needsFix' : 'none';
|
||||||
t === 'review_approved' ||
|
if (status === 'completed') {
|
||||||
t === 'review_started'
|
return reviewState === 'review' || reviewState === 'approved' ? reviewState : 'none';
|
||||||
) {
|
}
|
||||||
const to = typeof e.to === 'string' ? e.to : 'none';
|
return reviewState;
|
||||||
return to === 'review' || to === 'needsFix' || to === 'approved' ? to : 'none';
|
}
|
||||||
|
|
||||||
|
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';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
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 {
|
function normalizeComments(parsed: ParsedTask): unknown[] | undefined {
|
||||||
if (!Array.isArray(parsed.comments)) return undefined;
|
if (!Array.isArray(parsed.comments)) return undefined;
|
||||||
return (parsed.comments as unknown[])
|
return (parsed.comments as unknown[])
|
||||||
|
|
@ -869,7 +914,16 @@ async function readTasksDirForTeam(
|
||||||
? (parsed.needsClarification as string)
|
? (parsed.needsClarification as string)
|
||||||
: undefined;
|
: undefined;
|
||||||
const historyEvents = normalizeHistoryEvents(parsed);
|
const historyEvents = normalizeHistoryEvents(parsed);
|
||||||
const reviewState = deriveReviewStateFromEvents(historyEvents);
|
const status =
|
||||||
|
parsed.status === 'pending' ||
|
||||||
|
parsed.status === 'in_progress' ||
|
||||||
|
parsed.status === 'completed' ||
|
||||||
|
parsed.status === 'deleted'
|
||||||
|
? (parsed.status as string)
|
||||||
|
: 'pending';
|
||||||
|
const reviewState =
|
||||||
|
deriveReviewStateFromEvents(historyEvents) ??
|
||||||
|
normalizeFallbackReviewState(parsed.reviewState, status);
|
||||||
|
|
||||||
tasks.push({
|
tasks.push({
|
||||||
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
id: typeof parsed.id === 'string' || typeof parsed.id === 'number' ? String(parsed.id) : '',
|
||||||
|
|
@ -896,13 +950,7 @@ async function readTasksDirForTeam(
|
||||||
: undefined,
|
: undefined,
|
||||||
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
owner: typeof parsed.owner === 'string' ? parsed.owner : undefined,
|
||||||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||||
status:
|
status,
|
||||||
parsed.status === 'pending' ||
|
|
||||||
parsed.status === 'in_progress' ||
|
|
||||||
parsed.status === 'completed' ||
|
|
||||||
parsed.status === 'deleted'
|
|
||||||
? (parsed.status as string)
|
|
||||||
: 'pending',
|
|
||||||
workIntervals: normalizeWorkIntervals(parsed),
|
workIntervals: normalizeWorkIntervals(parsed),
|
||||||
historyEvents: normalizeHistoryEvents(parsed),
|
historyEvents: normalizeHistoryEvents(parsed),
|
||||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import {
|
||||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
|
||||||
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
import { TerminalLogPanel } from '@renderer/components/terminal/TerminalLogPanel';
|
||||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||||
|
|
@ -265,11 +264,8 @@ interface InstalledBannerProps {
|
||||||
cliStatusError: string | null;
|
cliStatusError: string | null;
|
||||||
providersCollapsed: boolean;
|
providersCollapsed: boolean;
|
||||||
isBusy: boolean;
|
isBusy: boolean;
|
||||||
multimodelEnabled: boolean;
|
|
||||||
multimodelBusy: boolean;
|
|
||||||
onInstall: () => void;
|
onInstall: () => void;
|
||||||
onRefresh: () => void;
|
onRefresh: () => void;
|
||||||
onMultimodelToggle: (enabled: boolean) => void;
|
|
||||||
onToggleProvidersCollapsed: () => void;
|
onToggleProvidersCollapsed: () => void;
|
||||||
onProviderLogin: (providerId: CliProviderId) => void;
|
onProviderLogin: (providerId: CliProviderId) => void;
|
||||||
onProviderLogout: (providerId: CliProviderId) => void;
|
onProviderLogout: (providerId: CliProviderId) => void;
|
||||||
|
|
@ -573,11 +569,8 @@ const InstalledBanner = ({
|
||||||
cliStatusError,
|
cliStatusError,
|
||||||
providersCollapsed,
|
providersCollapsed,
|
||||||
isBusy,
|
isBusy,
|
||||||
multimodelEnabled,
|
|
||||||
multimodelBusy,
|
|
||||||
onInstall,
|
onInstall,
|
||||||
onRefresh,
|
onRefresh,
|
||||||
onMultimodelToggle,
|
|
||||||
onToggleProvidersCollapsed,
|
onToggleProvidersCollapsed,
|
||||||
onProviderLogin,
|
onProviderLogin,
|
||||||
onProviderLogout,
|
onProviderLogout,
|
||||||
|
|
@ -683,23 +676,6 @@ const InstalledBanner = ({
|
||||||
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
<span className="text-xs font-medium" style={{ color: 'var(--color-text-secondary)' }}>
|
||||||
Multimodel
|
Multimodel
|
||||||
</span>
|
</span>
|
||||||
{multimodelEnabled && (
|
|
||||||
<span
|
|
||||||
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
|
|
||||||
style={{
|
|
||||||
borderColor: 'rgba(245, 158, 11, 0.35)',
|
|
||||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
|
||||||
color: '#fbbf24',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Beta
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<SettingsToggle
|
|
||||||
enabled={multimodelEnabled}
|
|
||||||
onChange={onMultimodelToggle}
|
|
||||||
disabled={isBusy || cliStatusLoading || multimodelBusy}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Extensions button — available whenever the runtime is installed */}
|
{/* Extensions button — available whenever the runtime is installed */}
|
||||||
{canOpenExtensions && (
|
{canOpenExtensions && (
|
||||||
|
|
@ -1033,7 +1009,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('anthropic');
|
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('anthropic');
|
||||||
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||||
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
const [isVerifyingAuth, setIsVerifyingAuth] = useState(false);
|
||||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
|
||||||
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
const [showTroubleshoot, setShowTroubleshoot] = useState(false);
|
||||||
const [providersCollapsed, setProvidersCollapsed] = useState(() =>
|
const [providersCollapsed, setProvidersCollapsed] = useState(() =>
|
||||||
loadDashboardCliStatusBannerCollapsed()
|
loadDashboardCliStatusBannerCollapsed()
|
||||||
|
|
@ -1147,37 +1122,6 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
})();
|
})();
|
||||||
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
}, [bootstrapCliStatus, codexAccount, fetchCliStatus, multimodelEnabled]);
|
||||||
|
|
||||||
const handleMultimodelToggle = useCallback(
|
|
||||||
async (enabled: boolean) => {
|
|
||||||
setIsSwitchingFlavor(true);
|
|
||||||
let nextMultimodelEnabled = multimodelEnabled;
|
|
||||||
try {
|
|
||||||
useStore.setState({
|
|
||||||
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
|
|
||||||
cliStatusLoading: true,
|
|
||||||
cliStatusError: null,
|
|
||||||
});
|
|
||||||
await updateConfig('general', { multimodelEnabled: enabled });
|
|
||||||
nextMultimodelEnabled = enabled;
|
|
||||||
await invalidateCliStatus();
|
|
||||||
if (enabled) {
|
|
||||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
|
||||||
} else {
|
|
||||||
await fetchCliStatus();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (nextMultimodelEnabled) {
|
|
||||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
|
||||||
} else {
|
|
||||||
await fetchCliStatus();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsSwitchingFlavor(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig]
|
|
||||||
);
|
|
||||||
|
|
||||||
const recheckAuthState = useCallback(() => {
|
const recheckAuthState = useCallback(() => {
|
||||||
setIsVerifyingAuth(true);
|
setIsVerifyingAuth(true);
|
||||||
void (async () => {
|
void (async () => {
|
||||||
|
|
@ -1422,11 +1366,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
cliStatusError={cliStatusError ?? null}
|
cliStatusError={cliStatusError ?? null}
|
||||||
providersCollapsed={providersCollapsed}
|
providersCollapsed={providersCollapsed}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
multimodelEnabled={multimodelEnabled}
|
|
||||||
multimodelBusy={isSwitchingFlavor}
|
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
onProviderLogout={handleProviderLogout}
|
onProviderLogout={handleProviderLogout}
|
||||||
|
|
@ -1650,11 +1591,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
cliStatusError={cliStatusError ?? null}
|
cliStatusError={cliStatusError ?? null}
|
||||||
providersCollapsed={providersCollapsed}
|
providersCollapsed={providersCollapsed}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
multimodelEnabled={multimodelEnabled}
|
|
||||||
multimodelBusy={isSwitchingFlavor}
|
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
onProviderLogout={handleProviderLogout}
|
onProviderLogout={handleProviderLogout}
|
||||||
|
|
@ -1712,11 +1650,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
cliStatusError={cliStatusError ?? null}
|
cliStatusError={cliStatusError ?? null}
|
||||||
providersCollapsed={providersCollapsed}
|
providersCollapsed={providersCollapsed}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
multimodelEnabled={multimodelEnabled}
|
|
||||||
multimodelBusy={isSwitchingFlavor}
|
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
onProviderLogout={handleProviderLogout}
|
onProviderLogout={handleProviderLogout}
|
||||||
|
|
@ -1934,11 +1869,8 @@ export const CliStatusBanner = (): React.JSX.Element | null => {
|
||||||
cliStatusError={cliStatusError ?? null}
|
cliStatusError={cliStatusError ?? null}
|
||||||
providersCollapsed={providersCollapsed}
|
providersCollapsed={providersCollapsed}
|
||||||
isBusy={isBusy}
|
isBusy={isBusy}
|
||||||
multimodelEnabled={multimodelEnabled}
|
|
||||||
multimodelBusy={isSwitchingFlavor}
|
|
||||||
onInstall={handleInstall}
|
onInstall={handleInstall}
|
||||||
onRefresh={handleRefresh}
|
onRefresh={handleRefresh}
|
||||||
onMultimodelToggle={(enabled) => void handleMultimodelToggle(enabled)}
|
|
||||||
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
onToggleProvidersCollapsed={handleToggleProvidersCollapsed}
|
||||||
onProviderLogin={handleProviderLogin}
|
onProviderLogin={handleProviderLogin}
|
||||||
onProviderLogout={handleProviderLogout}
|
onProviderLogout={handleProviderLogout}
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,6 @@ import {
|
||||||
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
import { ProviderModelBadges } from '@renderer/components/runtime/ProviderModelBadges';
|
||||||
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
import { getProviderRuntimeBackendSummary } from '@renderer/components/runtime/ProviderRuntimeBackendSelector';
|
||||||
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
import { ProviderRuntimeSettingsDialog } from '@renderer/components/runtime/ProviderRuntimeSettingsDialog';
|
||||||
import { SettingsToggle } from '@renderer/components/settings/components';
|
|
||||||
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
import { TerminalModal } from '@renderer/components/terminal/TerminalModal';
|
||||||
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
import { useCliInstaller } from '@renderer/hooks/useCliInstaller';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
@ -211,7 +210,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
} | null>(null);
|
} | null>(null);
|
||||||
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
|
const [manageProviderId, setManageProviderId] = useState<CliProviderId>('gemini');
|
||||||
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
const [manageDialogOpen, setManageDialogOpen] = useState(false);
|
||||||
const [isSwitchingFlavor, setIsSwitchingFlavor] = useState(false);
|
|
||||||
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
const multimodelEnabled = appConfig?.general?.multimodelEnabled ?? true;
|
||||||
const loadingCliStatus =
|
const loadingCliStatus =
|
||||||
!cliStatus && cliStatusLoading && multimodelEnabled
|
!cliStatus && cliStatusLoading && multimodelEnabled
|
||||||
|
|
@ -323,37 +321,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
})();
|
})();
|
||||||
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
|
}, [bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled]);
|
||||||
|
|
||||||
const handleMultimodelToggle = useCallback(
|
|
||||||
async (enabled: boolean) => {
|
|
||||||
setIsSwitchingFlavor(true);
|
|
||||||
let nextMultimodelEnabled = multimodelEnabled;
|
|
||||||
try {
|
|
||||||
useStore.setState({
|
|
||||||
cliStatus: enabled ? createLoadingMultimodelCliStatus() : null,
|
|
||||||
cliStatusLoading: true,
|
|
||||||
cliStatusError: null,
|
|
||||||
});
|
|
||||||
await updateConfig('general', { multimodelEnabled: enabled });
|
|
||||||
nextMultimodelEnabled = enabled;
|
|
||||||
await invalidateCliStatus();
|
|
||||||
if (enabled) {
|
|
||||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
|
||||||
} else {
|
|
||||||
await fetchCliStatus();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
if (nextMultimodelEnabled) {
|
|
||||||
await bootstrapCliStatus({ multimodelEnabled: true });
|
|
||||||
} else {
|
|
||||||
await fetchCliStatus();
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
setIsSwitchingFlavor(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[bootstrapCliStatus, fetchCliStatus, invalidateCliStatus, multimodelEnabled, updateConfig]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleRuntimeBackendChange = useCallback(
|
const handleRuntimeBackendChange = useCallback(
|
||||||
async (providerId: CliProviderId, backendId: string) => {
|
async (providerId: CliProviderId, backendId: string) => {
|
||||||
const currentBackends = appConfig?.runtime?.providerBackends ?? {
|
const currentBackends = appConfig?.runtime?.providerBackends ?? {
|
||||||
|
|
@ -440,23 +407,6 @@ export const CliStatusSection = (): React.JSX.Element | null => {
|
||||||
>
|
>
|
||||||
Multimodel
|
Multimodel
|
||||||
</span>
|
</span>
|
||||||
{multimodelEnabled && (
|
|
||||||
<span
|
|
||||||
className="rounded-full border px-1.5 py-0.5 text-[10px] font-semibold uppercase tracking-[0.08em]"
|
|
||||||
style={{
|
|
||||||
borderColor: 'rgba(245, 158, 11, 0.35)',
|
|
||||||
backgroundColor: 'rgba(245, 158, 11, 0.1)',
|
|
||||||
color: '#fbbf24',
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Beta
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
<SettingsToggle
|
|
||||||
enabled={multimodelEnabled}
|
|
||||||
onChange={(value) => void handleMultimodelToggle(value)}
|
|
||||||
disabled={isBusy || cliStatusLoading || isSwitchingFlavor}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
{/* Inline action buttons */}
|
{/* Inline action buttons */}
|
||||||
{effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (
|
{effectiveCliStatus.supportsSelfUpdate && effectiveCliStatus.updateAvailable ? (
|
||||||
|
|
|
||||||
|
|
@ -167,6 +167,9 @@ export const ProvisioningProgressBlock = ({
|
||||||
const outputScrollRef = useRef<HTMLDivElement>(null);
|
const outputScrollRef = useRef<HTMLDivElement>(null);
|
||||||
const isError = tone === 'error';
|
const isError = tone === 'error';
|
||||||
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
|
const displayAssistantOutput = sanitizeAssistantOutput(assistantOutput, isError);
|
||||||
|
const visibleLaunchDiagnostics =
|
||||||
|
launchDiagnostics?.filter((item) => item.severity === 'warning' || item.severity === 'error') ??
|
||||||
|
[];
|
||||||
|
|
||||||
// Auto-scroll assistant output
|
// Auto-scroll assistant output
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -298,7 +301,7 @@ export const ProvisioningProgressBlock = ({
|
||||||
errorIndex={errorStepIndex}
|
errorIndex={errorStepIndex}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
{launchDiagnostics && launchDiagnostics.length > 0 ? (
|
{visibleLaunchDiagnostics.length > 0 ? (
|
||||||
<div className="mt-2">
|
<div className="mt-2">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|
@ -310,7 +313,7 @@ export const ProvisioningProgressBlock = ({
|
||||||
</button>
|
</button>
|
||||||
{diagnosticsOpen ? (
|
{diagnosticsOpen ? (
|
||||||
<div className="mt-1 space-y-1 rounded border border-[var(--color-border)] bg-[var(--color-surface)] p-2">
|
<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 key={item.id} className="text-[11px]">
|
||||||
<div
|
<div
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
|
||||||
|
|
@ -257,7 +257,7 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
|
||||||
memberCount: team.memberCount,
|
memberCount: team.memberCount,
|
||||||
expectedMemberCount: team.expectedMemberCount,
|
expectedMemberCount: team.expectedMemberCount,
|
||||||
confirmedCount: team.confirmedCount,
|
confirmedCount: team.confirmedCount,
|
||||||
runtimeAlivePendingCount: team.runtimeAlivePendingCount,
|
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
|
||||||
teamLaunchState: team.teamLaunchState,
|
teamLaunchState: team.teamLaunchState,
|
||||||
partialLaunchFailure: team.partialLaunchFailure,
|
partialLaunchFailure: team.partialLaunchFailure,
|
||||||
missingMemberCount: team.missingMembers?.length ?? 0,
|
missingMemberCount: team.missingMembers?.length ?? 0,
|
||||||
|
|
@ -267,12 +267,12 @@ const TeamOfflineStatusBanner = memo(function TeamOfflineStatusBanner({
|
||||||
|
|
||||||
const message =
|
const message =
|
||||||
summary?.teamLaunchState === 'partial_pending'
|
summary?.teamLaunchState === 'partial_pending'
|
||||||
? summary.runtimeAlivePendingCount != null && summary.runtimeAlivePendingCount > 0
|
? summary.runtimeProcessPendingCount != null && summary.runtimeProcessPendingCount > 0
|
||||||
? buildPendingRuntimeSummaryCopy({
|
? buildPendingRuntimeSummaryCopy({
|
||||||
confirmedCount: summary.confirmedCount,
|
confirmedCount: summary.confirmedCount,
|
||||||
expectedMemberCount: summary.expectedMemberCount,
|
expectedMemberCount: summary.expectedMemberCount,
|
||||||
memberCount: summary.memberCount,
|
memberCount: summary.memberCount,
|
||||||
runtimeAlivePendingCount: summary.runtimeAlivePendingCount,
|
runtimeProcessPendingCount: summary.runtimeProcessPendingCount,
|
||||||
})
|
})
|
||||||
: 'Last launch is still reconciling'
|
: 'Last launch is still reconciling'
|
||||||
: summary?.partialLaunchFailure
|
: summary?.partialLaunchFailure
|
||||||
|
|
@ -809,6 +809,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
||||||
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
|
() => buildTeamAgentRuntimeMap(runtimeSnapshot?.members),
|
||||||
[runtimeSnapshot?.members]
|
[runtimeSnapshot?.members]
|
||||||
);
|
);
|
||||||
|
const runtimeRunId = runtimeSnapshot?.runId ?? memberSpawnSnapshot?.runId ?? progress?.runId;
|
||||||
const isLaunchSettling = useMemo(() => {
|
const isLaunchSettling = useMemo(() => {
|
||||||
if (progress?.state !== 'ready') {
|
if (progress?.state !== 'ready') {
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -828,6 +829,7 @@ const TeamMemberListBridge = memo(function TeamMemberListBridge({
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
memberSpawnStatuses={memberSpawnStatusMap}
|
memberSpawnStatuses={memberSpawnStatusMap}
|
||||||
memberRuntimeEntries={memberRuntimeMap}
|
memberRuntimeEntries={memberRuntimeMap}
|
||||||
|
runtimeRunId={runtimeRunId}
|
||||||
isLaunchSettling={isLaunchSettling}
|
isLaunchSettling={isLaunchSettling}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
|
|
@ -889,6 +891,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
|
runtimeRunId,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
useShallow((s) => ({
|
useShallow((s) => ({
|
||||||
|
|
@ -899,6 +902,10 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
memberSpawnStatuses: s.memberSpawnStatusesByTeam[teamName],
|
||||||
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
memberSpawnSnapshot: s.memberSpawnSnapshotsByTeam[teamName],
|
||||||
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
spawnEntry: member ? s.memberSpawnStatusesByTeam[teamName]?.[member.name] : undefined,
|
||||||
|
runtimeRunId:
|
||||||
|
s.teamAgentRuntimeByTeam[teamName]?.runId ??
|
||||||
|
s.memberSpawnSnapshotsByTeam[teamName]?.runId ??
|
||||||
|
getCurrentProvisioningProgressForTeam(s, teamName)?.runId,
|
||||||
runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined,
|
runtimeEntry: member ? s.teamAgentRuntimeByTeam[teamName]?.members[member.name] : undefined,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
@ -924,6 +931,7 @@ const TeamMemberDetailDialogBridge = memo(function TeamMemberDetailDialogBridge(
|
||||||
leadActivity={leadActivity}
|
leadActivity={leadActivity}
|
||||||
spawnEntry={spawnEntry}
|
spawnEntry={spawnEntry}
|
||||||
runtimeEntry={runtimeEntry}
|
runtimeEntry={runtimeEntry}
|
||||||
|
runtimeRunId={runtimeRunId}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -981,12 +981,12 @@ export const TeamListView = (): React.JSX.Element => {
|
||||||
</div>
|
</div>
|
||||||
{team.teamLaunchState === 'partial_pending' ? (
|
{team.teamLaunchState === 'partial_pending' ? (
|
||||||
<p className="mt-2 text-[11px] text-amber-300">
|
<p className="mt-2 text-[11px] text-amber-300">
|
||||||
{team.runtimeAlivePendingCount && team.runtimeAlivePendingCount > 0
|
{team.runtimeProcessPendingCount && team.runtimeProcessPendingCount > 0
|
||||||
? buildPendingRuntimeSummaryCopy({
|
? buildPendingRuntimeSummaryCopy({
|
||||||
confirmedCount: team.confirmedCount,
|
confirmedCount: team.confirmedCount,
|
||||||
expectedMemberCount: team.expectedMemberCount,
|
expectedMemberCount: team.expectedMemberCount,
|
||||||
memberCount: team.memberCount,
|
memberCount: team.memberCount,
|
||||||
runtimeAlivePendingCount: team.runtimeAlivePendingCount,
|
runtimeProcessPendingCount: team.runtimeProcessPendingCount,
|
||||||
includePeriod: true,
|
includePeriod: true,
|
||||||
})
|
})
|
||||||
: 'Last launch is still reconciling.'}
|
: 'Last launch is still reconciling.'}
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,8 @@ import { isTeamProviderId, normalizeOptionalTeamProviderId } from '@shared/utils
|
||||||
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
import { AlertTriangle, CheckCircle2, Info, Loader2, X } from 'lucide-react';
|
||||||
|
|
||||||
import { AdvancedCliSection } from './AdvancedCliSection';
|
import { AdvancedCliSection } from './AdvancedCliSection';
|
||||||
|
import { AnthropicFastModeSelector } from './AnthropicFastModeSelector';
|
||||||
|
import { CodexFastModeSelector } from './CodexFastModeSelector';
|
||||||
import {
|
import {
|
||||||
clearInheritedMemberModelsUnavailableForProvider,
|
clearInheritedMemberModelsUnavailableForProvider,
|
||||||
resolveProviderScopedMemberModel,
|
resolveProviderScopedMemberModel,
|
||||||
|
|
@ -1421,10 +1423,12 @@ export const CreateTeamDialog = ({
|
||||||
const summary: string[] = [];
|
const summary: string[] = [];
|
||||||
if (prompt.trim()) summary.push('Lead prompt');
|
if (prompt.trim()) summary.push('Lead prompt');
|
||||||
if (skipPermissions) summary.push('Auto-approve tools');
|
if (skipPermissions) summary.push('Auto-approve tools');
|
||||||
if (selectedProviderId === 'anthropic') {
|
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
|
||||||
if (selectedFastMode === 'on') summary.push('Fast mode');
|
if (selectedFastMode === 'on') summary.push('Fast mode');
|
||||||
else if (selectedFastMode === 'off') summary.push('Fast disabled');
|
else if (selectedFastMode === 'off') summary.push('Fast disabled');
|
||||||
else if (anthropicProviderFastModeDefault) summary.push('Fast default');
|
else if (selectedProviderId === 'anthropic' && anthropicProviderFastModeDefault) {
|
||||||
|
summary.push('Fast default');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
if (worktreeEnabled && worktreeName.trim()) summary.push(`Worktree: ${worktreeName.trim()}`);
|
||||||
if (customArgs.trim()) summary.push('Custom CLI args');
|
if (customArgs.trim()) summary.push('Custom CLI args');
|
||||||
|
|
@ -1721,9 +1725,6 @@ export const CreateTeamDialog = ({
|
||||||
onProviderChange={setSelectedProviderId}
|
onProviderChange={setSelectedProviderId}
|
||||||
onModelChange={setSelectedModel}
|
onModelChange={setSelectedModel}
|
||||||
onEffortChange={setSelectedEffort}
|
onEffortChange={setSelectedEffort}
|
||||||
fastMode={selectedFastMode}
|
|
||||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
|
||||||
onFastModeChange={setSelectedFastMode}
|
|
||||||
onLimitContextChange={setLimitContext}
|
onLimitContextChange={setLimitContext}
|
||||||
syncModelsWithTeammates={syncModelsWithLead}
|
syncModelsWithTeammates={syncModelsWithLead}
|
||||||
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
onSyncModelsWithTeammatesChange={handleSyncModelsWithLeadChange}
|
||||||
|
|
@ -1732,7 +1733,6 @@ export const CreateTeamDialog = ({
|
||||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||||
disableGeminiOption={isGeminiUiFrozen()}
|
disableGeminiOption={isGeminiUiFrozen()}
|
||||||
leadModelIssueText={leadModelIssueText}
|
leadModelIssueText={leadModelIssueText}
|
||||||
leadFastModeNotice={anthropicRuntimeNotice}
|
|
||||||
memberModelIssueById={memberModelIssueById}
|
memberModelIssueById={memberModelIssueById}
|
||||||
headerTop={
|
headerTop={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
|
|
@ -1818,6 +1818,47 @@ export const CreateTeamDialog = ({
|
||||||
summary={launchOptionalSummary}
|
summary={launchOptionalSummary}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{selectedProviderId === 'anthropic' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<AnthropicFastModeSelector
|
||||||
|
value={selectedFastMode}
|
||||||
|
onValueChange={setSelectedFastMode}
|
||||||
|
providerFastModeDefault={anthropicProviderFastModeDefault}
|
||||||
|
model={selectedModel}
|
||||||
|
limitContext={limitContext}
|
||||||
|
id="create-fast-mode"
|
||||||
|
/>
|
||||||
|
{anthropicRuntimeNotice ? (
|
||||||
|
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||||
|
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||||
|
<p>{anthropicRuntimeNotice}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedProviderId === 'codex' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<CodexFastModeSelector
|
||||||
|
value={selectedFastMode}
|
||||||
|
onValueChange={setSelectedFastMode}
|
||||||
|
model={selectedModel}
|
||||||
|
providerBackendId={
|
||||||
|
resolveUiOwnedProviderBackendId(
|
||||||
|
'codex',
|
||||||
|
runtimeProviderStatusById.get('codex')
|
||||||
|
) ?? undefined
|
||||||
|
}
|
||||||
|
id="create-fast-mode"
|
||||||
|
/>
|
||||||
|
{anthropicRuntimeNotice ? (
|
||||||
|
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||||
|
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||||
|
<p>{anthropicRuntimeNotice}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<div className="space-y-1.5">
|
<div className="space-y-1.5">
|
||||||
<Label htmlFor="team-prompt" className="label-optional">
|
<Label htmlFor="team-prompt" className="label-optional">
|
||||||
Prompt for team lead (optional)
|
Prompt for team lead (optional)
|
||||||
|
|
|
||||||
|
|
@ -79,6 +79,7 @@ import {
|
||||||
CheckCircle2,
|
CheckCircle2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
ChevronRight,
|
ChevronRight,
|
||||||
|
Info,
|
||||||
Loader2,
|
Loader2,
|
||||||
RotateCcw,
|
RotateCcw,
|
||||||
X,
|
X,
|
||||||
|
|
@ -1618,10 +1619,12 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
|
summary.push(`Provider: ${getProviderLabel(selectedProviderId)}`);
|
||||||
if (selectedModel) summary.push(`Model: ${selectedModel}`);
|
if (selectedModel) summary.push(`Model: ${selectedModel}`);
|
||||||
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
|
if (selectedEffort) summary.push(`Effort: ${selectedEffort}`);
|
||||||
if (selectedProviderId === 'anthropic') {
|
if (selectedProviderId === 'anthropic' || selectedProviderId === 'codex') {
|
||||||
if (selectedFastMode === 'on') summary.push('Fast mode');
|
if (selectedFastMode === 'on') summary.push('Fast mode');
|
||||||
else if (selectedFastMode === 'off') summary.push('Fast disabled');
|
else if (selectedFastMode === 'off') summary.push('Fast disabled');
|
||||||
else if (anthropicProviderFastModeDefault) summary.push('Fast default');
|
else if (selectedProviderId === 'anthropic' && anthropicProviderFastModeDefault) {
|
||||||
|
summary.push('Fast default');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context');
|
if (selectedProviderId === 'anthropic' && limitContext) summary.push('Limited to 200K context');
|
||||||
if (skipPermissions) summary.push('Auto-approve tools');
|
if (skipPermissions) summary.push('Auto-approve tools');
|
||||||
|
|
@ -2263,6 +2266,52 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
summary={launchOptionalSummary}
|
summary={launchOptionalSummary}
|
||||||
>
|
>
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
|
{selectedProviderId === 'anthropic' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<AnthropicFastModeSelector
|
||||||
|
value={selectedFastMode}
|
||||||
|
onValueChange={setSelectedFastMode}
|
||||||
|
providerFastModeDefault={anthropicProviderFastModeDefault}
|
||||||
|
model={selectedModel}
|
||||||
|
limitContext={effectiveAnthropicRuntimeLimitContext}
|
||||||
|
id="launch-fast-mode"
|
||||||
|
/>
|
||||||
|
{anthropicRuntimeNotice ? (
|
||||||
|
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||||
|
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||||
|
<p>{anthropicRuntimeNotice}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
{selectedProviderId === 'codex' ? (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<CodexFastModeSelector
|
||||||
|
value={selectedFastMode}
|
||||||
|
onValueChange={setSelectedFastMode}
|
||||||
|
model={selectedModel}
|
||||||
|
providerBackendId={
|
||||||
|
resolveUiOwnedProviderBackendId(
|
||||||
|
'codex',
|
||||||
|
runtimeProviderStatusById.get('codex')
|
||||||
|
) ??
|
||||||
|
migrateProviderBackendId(
|
||||||
|
'codex',
|
||||||
|
previousLaunchParams?.providerBackendId ?? savedLaunchProviderBackendId
|
||||||
|
) ??
|
||||||
|
undefined
|
||||||
|
}
|
||||||
|
id="launch-fast-mode"
|
||||||
|
/>
|
||||||
|
{anthropicRuntimeNotice ? (
|
||||||
|
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
||||||
|
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
||||||
|
<p>{anthropicRuntimeNotice}</p>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
|
||||||
<TeamRosterEditorSection
|
<TeamRosterEditorSection
|
||||||
members={membersDrafts}
|
members={membersDrafts}
|
||||||
onMembersChange={setMembersDrafts}
|
onMembersChange={setMembersDrafts}
|
||||||
|
|
@ -2285,13 +2334,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
providerId={selectedProviderId}
|
providerId={selectedProviderId}
|
||||||
model={selectedModel}
|
model={selectedModel}
|
||||||
effort={(selectedEffort as EffortLevel) || undefined}
|
effort={(selectedEffort as EffortLevel) || undefined}
|
||||||
fastMode={selectedFastMode}
|
|
||||||
providerFastModeDefault={anthropicProviderFastModeDefault}
|
|
||||||
limitContext={limitContext}
|
limitContext={limitContext}
|
||||||
onProviderChange={setSelectedProviderId}
|
onProviderChange={setSelectedProviderId}
|
||||||
onModelChange={setSelectedModel}
|
onModelChange={setSelectedModel}
|
||||||
onEffortChange={setSelectedEffort}
|
onEffortChange={setSelectedEffort}
|
||||||
onFastModeChange={setSelectedFastMode}
|
|
||||||
onLimitContextChange={setLimitContext}
|
onLimitContextChange={setLimitContext}
|
||||||
syncModelsWithTeammates={syncModelsWithLead}
|
syncModelsWithTeammates={syncModelsWithLead}
|
||||||
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
onSyncModelsWithTeammatesChange={setSyncModelsWithLead}
|
||||||
|
|
@ -2299,7 +2345,6 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
||||||
teammateWorktreeDefault={teammateWorktreeDefault}
|
teammateWorktreeDefault={teammateWorktreeDefault}
|
||||||
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
onTeammateWorktreeDefaultChange={setTeammateWorktreeDefault}
|
||||||
leadWarningText={leadRuntimeWarningText}
|
leadWarningText={leadRuntimeWarningText}
|
||||||
leadFastModeNotice={anthropicRuntimeNotice}
|
|
||||||
memberWarningById={memberRuntimeWarningById}
|
memberWarningById={memberRuntimeWarningById}
|
||||||
leadModelIssueText={leadModelIssueText}
|
leadModelIssueText={leadModelIssueText}
|
||||||
memberModelIssueById={memberModelIssueById}
|
memberModelIssueById={memberModelIssueById}
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
|
|
||||||
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
import { ProviderBrandLogo } from '@renderer/components/common/ProviderBrandLogo';
|
||||||
import { AnthropicFastModeSelector } from '@renderer/components/team/dialogs/AnthropicFastModeSelector';
|
|
||||||
import { CodexFastModeSelector } from '@renderer/components/team/dialogs/CodexFastModeSelector';
|
|
||||||
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
import { EffortLevelSelector } from '@renderer/components/team/dialogs/EffortLevelSelector';
|
||||||
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
|
import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitContextCheckbox';
|
||||||
import {
|
import {
|
||||||
|
|
@ -22,24 +20,20 @@ import { AlertTriangle, ChevronDown, ChevronRight, Info } from 'lucide-react';
|
||||||
|
|
||||||
import { Button } from '../../ui/button';
|
import { Button } from '../../ui/button';
|
||||||
|
|
||||||
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
|
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
interface LeadModelRowProps {
|
interface LeadModelRowProps {
|
||||||
providerId: TeamProviderId;
|
providerId: TeamProviderId;
|
||||||
model: string;
|
model: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
fastMode?: TeamFastMode;
|
|
||||||
providerFastModeDefault?: boolean;
|
|
||||||
limitContext: boolean;
|
limitContext: boolean;
|
||||||
onProviderChange: (providerId: TeamProviderId) => void;
|
onProviderChange: (providerId: TeamProviderId) => void;
|
||||||
onModelChange: (model: string) => void;
|
onModelChange: (model: string) => void;
|
||||||
onEffortChange: (effort: string) => void;
|
onEffortChange: (effort: string) => void;
|
||||||
onFastModeChange?: (fastMode: TeamFastMode) => void;
|
|
||||||
onLimitContextChange: (value: boolean) => void;
|
onLimitContextChange: (value: boolean) => void;
|
||||||
syncModelsWithTeammates: boolean;
|
syncModelsWithTeammates: boolean;
|
||||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||||
warningText?: string | null;
|
warningText?: string | null;
|
||||||
fastModeNotice?: string | null;
|
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
modelIssueText?: string | null;
|
modelIssueText?: string | null;
|
||||||
}
|
}
|
||||||
|
|
@ -48,18 +42,14 @@ export const LeadModelRow = ({
|
||||||
providerId,
|
providerId,
|
||||||
model,
|
model,
|
||||||
effort,
|
effort,
|
||||||
fastMode = 'inherit',
|
|
||||||
providerFastModeDefault = false,
|
|
||||||
limitContext,
|
limitContext,
|
||||||
onProviderChange,
|
onProviderChange,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
onEffortChange,
|
onEffortChange,
|
||||||
onFastModeChange,
|
|
||||||
onLimitContextChange,
|
onLimitContextChange,
|
||||||
syncModelsWithTeammates,
|
syncModelsWithTeammates,
|
||||||
onSyncModelsWithTeammatesChange,
|
onSyncModelsWithTeammatesChange,
|
||||||
warningText,
|
warningText,
|
||||||
fastModeNotice,
|
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
modelIssueText,
|
modelIssueText,
|
||||||
}: LeadModelRowProps): React.JSX.Element => {
|
}: LeadModelRowProps): React.JSX.Element => {
|
||||||
|
|
@ -169,24 +159,6 @@ export const LeadModelRow = ({
|
||||||
model={model}
|
model={model}
|
||||||
limitContext={limitContext}
|
limitContext={limitContext}
|
||||||
/>
|
/>
|
||||||
{providerId === 'anthropic' && onFastModeChange ? (
|
|
||||||
<AnthropicFastModeSelector
|
|
||||||
value={fastMode}
|
|
||||||
onValueChange={onFastModeChange}
|
|
||||||
providerFastModeDefault={providerFastModeDefault}
|
|
||||||
model={model}
|
|
||||||
limitContext={limitContext}
|
|
||||||
id="lead-fast-mode"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{providerId === 'codex' && onFastModeChange ? (
|
|
||||||
<CodexFastModeSelector
|
|
||||||
value={fastMode}
|
|
||||||
onValueChange={onFastModeChange}
|
|
||||||
model={model}
|
|
||||||
id="lead-fast-mode"
|
|
||||||
/>
|
|
||||||
) : null}
|
|
||||||
{providerId === 'anthropic' ? (
|
{providerId === 'anthropic' ? (
|
||||||
<LimitContextCheckbox
|
<LimitContextCheckbox
|
||||||
id="lead-limit-context"
|
id="lead-limit-context"
|
||||||
|
|
@ -195,12 +167,6 @@ export const LeadModelRow = ({
|
||||||
disabled={isAnthropicHaikuTeamModel(model)}
|
disabled={isAnthropicHaikuTeamModel(model)}
|
||||||
/>
|
/>
|
||||||
) : null}
|
) : null}
|
||||||
{fastModeNotice ? (
|
|
||||||
<div className="bg-amber-500/8 flex items-start gap-2 rounded-md border border-amber-500/25 px-3 py-2 text-[11px] leading-relaxed text-amber-200">
|
|
||||||
<Info className="mt-0.5 size-3.5 shrink-0 text-amber-300" />
|
|
||||||
<p>{fastModeNotice}</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
<div className="flex items-start gap-2 rounded-md border border-sky-500/20 bg-sky-500/5 px-3 py-2">
|
||||||
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
<Info className="mt-0.5 size-3.5 shrink-0 text-sky-400" />
|
||||||
<p className="text-[11px] leading-relaxed text-sky-300">
|
<p className="text-[11px] leading-relaxed text-sky-300">
|
||||||
|
|
|
||||||
|
|
@ -13,10 +13,17 @@ import {
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import {
|
||||||
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
|
hasMemberLaunchDiagnosticsDetails,
|
||||||
|
hasMemberLaunchDiagnosticsError,
|
||||||
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
|
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
||||||
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
import { deriveTaskDisplayId } from '@shared/utils/taskIdentity';
|
||||||
import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
import { AlertTriangle, GitBranch, Loader2, MessageSquare, Plus } from 'lucide-react';
|
||||||
|
|
||||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
|
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||||
|
|
||||||
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
import type { TaskStatusCounts } from '@renderer/utils/pathNormalize';
|
||||||
import type {
|
import type {
|
||||||
|
|
@ -24,6 +31,7 @@ import type {
|
||||||
MemberLaunchState,
|
MemberLaunchState,
|
||||||
MemberSpawnLivenessSource,
|
MemberSpawnLivenessSource,
|
||||||
MemberSpawnStatus,
|
MemberSpawnStatus,
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamAgentRuntimeEntry,
|
TeamAgentRuntimeEntry,
|
||||||
TeamTaskWithKanban,
|
TeamTaskWithKanban,
|
||||||
|
|
@ -34,6 +42,7 @@ interface MemberCardProps {
|
||||||
memberColor: string;
|
memberColor: string;
|
||||||
runtimeSummary?: string;
|
runtimeSummary?: string;
|
||||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
|
runtimeRunId?: string | null;
|
||||||
taskCounts?: TaskStatusCounts | null;
|
taskCounts?: TaskStatusCounts | null;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
|
@ -43,6 +52,7 @@ interface MemberCardProps {
|
||||||
isAwaitingReply?: boolean;
|
isAwaitingReply?: boolean;
|
||||||
isRemoved?: boolean;
|
isRemoved?: boolean;
|
||||||
spawnStatus?: MemberSpawnStatus;
|
spawnStatus?: MemberSpawnStatus;
|
||||||
|
spawnEntry?: MemberSpawnStatusEntry;
|
||||||
spawnError?: string;
|
spawnError?: string;
|
||||||
spawnLivenessSource?: MemberSpawnLivenessSource;
|
spawnLivenessSource?: MemberSpawnLivenessSource;
|
||||||
spawnLaunchState?: MemberLaunchState;
|
spawnLaunchState?: MemberLaunchState;
|
||||||
|
|
@ -80,6 +90,7 @@ export const MemberCard = ({
|
||||||
memberColor,
|
memberColor,
|
||||||
runtimeSummary,
|
runtimeSummary,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
|
runtimeRunId,
|
||||||
taskCounts,
|
taskCounts,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
|
@ -89,6 +100,7 @@ export const MemberCard = ({
|
||||||
isAwaitingReply,
|
isAwaitingReply,
|
||||||
isRemoved,
|
isRemoved,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
spawnEntry,
|
||||||
spawnError,
|
spawnError,
|
||||||
spawnLivenessSource,
|
spawnLivenessSource,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
|
|
@ -150,6 +162,7 @@ export const MemberCard = ({
|
||||||
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
const roleLabel = formatAgentRole(member.role) ?? formatAgentRole(member.agentType);
|
||||||
const { summary: runtimeSummaryText, memory: memoryLabel } =
|
const { summary: runtimeSummaryText, memory: memoryLabel } =
|
||||||
splitRuntimeSummaryMemory(runtimeSummary);
|
splitRuntimeSummaryMemory(runtimeSummary);
|
||||||
|
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
||||||
const activityTask = currentTask ?? reviewTask ?? null;
|
const activityTask = currentTask ?? reviewTask ?? null;
|
||||||
const activityTitle = currentTask
|
const activityTitle = currentTask
|
||||||
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
? `Current task: #${deriveTaskDisplayId(currentTask.id)}`
|
||||||
|
|
@ -174,6 +187,33 @@ export const MemberCard = ({
|
||||||
launchVisualState === 'registered_only' ||
|
launchVisualState === 'registered_only' ||
|
||||||
launchVisualState === 'stale_runtime');
|
launchVisualState === 'stale_runtime');
|
||||||
const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel;
|
const launchBadgeLabel = presenceLabel === 'starting' ? presenceLabel : displayPresenceLabel;
|
||||||
|
const launchDiagnosticsPayload = useMemo(
|
||||||
|
() =>
|
||||||
|
buildMemberLaunchDiagnosticsPayload({
|
||||||
|
teamName: selectedTeamName,
|
||||||
|
runId: runtimeRunId,
|
||||||
|
memberName: member.name,
|
||||||
|
spawnStatus,
|
||||||
|
launchState: spawnLaunchState,
|
||||||
|
livenessSource: spawnLivenessSource,
|
||||||
|
spawnEntry,
|
||||||
|
runtimeEntry,
|
||||||
|
}),
|
||||||
|
[
|
||||||
|
member.name,
|
||||||
|
runtimeEntry,
|
||||||
|
runtimeRunId,
|
||||||
|
selectedTeamName,
|
||||||
|
spawnEntry,
|
||||||
|
spawnLaunchState,
|
||||||
|
spawnLivenessSource,
|
||||||
|
spawnStatus,
|
||||||
|
]
|
||||||
|
);
|
||||||
|
const showCopyDiagnostics =
|
||||||
|
!isRemoved &&
|
||||||
|
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||||
|
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||||
const showRuntimeAdvisoryBadge =
|
const showRuntimeAdvisoryBadge =
|
||||||
!isRemoved &&
|
!isRemoved &&
|
||||||
Boolean(runtimeAdvisoryLabel) &&
|
Boolean(runtimeAdvisoryLabel) &&
|
||||||
|
|
@ -297,16 +337,7 @@ export const MemberCard = ({
|
||||||
<span className="shrink-0 opacity-60">•</span>
|
<span className="shrink-0 opacity-60">•</span>
|
||||||
) : null}
|
) : null}
|
||||||
{memoryLabel ? (
|
{memoryLabel ? (
|
||||||
<span
|
<span className="shrink-0" title={memorySourceLabel}>
|
||||||
className="shrink-0"
|
|
||||||
title={
|
|
||||||
runtimeEntry?.pidSource === 'tmux_pane'
|
|
||||||
? 'RSS source: tmux pane shell'
|
|
||||||
: runtimeEntry?.pidSource
|
|
||||||
? `PID source: ${runtimeEntry.pidSource}`
|
|
||||||
: undefined
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{memoryLabel}
|
{memoryLabel}
|
||||||
</span>
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -330,20 +361,28 @@ export const MemberCard = ({
|
||||||
</Badge>
|
</Badge>
|
||||||
</span>
|
</span>
|
||||||
) : spawnStatus === 'error' ? (
|
) : spawnStatus === 'error' ? (
|
||||||
<Tooltip>
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
<TooltipTrigger asChild>
|
<Tooltip>
|
||||||
<span className="flex shrink-0 items-center gap-1">
|
<TooltipTrigger asChild>
|
||||||
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
<Badge
|
<AlertTriangle className="size-3.5 shrink-0 text-red-400" />
|
||||||
variant="secondary"
|
<Badge
|
||||||
className="shrink-0 bg-red-500/15 px-1.5 py-0.5 text-[10px] font-normal leading-none text-red-400"
|
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>
|
{displayPresenceLabel}
|
||||||
</span>
|
</Badge>
|
||||||
</TooltipTrigger>
|
</span>
|
||||||
<TooltipContent side="bottom">{spawnError ?? 'Spawn failed'}</TooltipContent>
|
</TooltipTrigger>
|
||||||
</Tooltip>
|
<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 ? (
|
) : showRuntimeAdvisoryBadge ? (
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,16 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from '@renderer/components/u
|
||||||
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
import { useMemberStats } from '@renderer/hooks/useMemberStats';
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
import { selectMemberMessagesForTeamMember } from '@renderer/store/slices/teamSlice';
|
||||||
import { resolveMemberRuntimeSummary } from '@renderer/utils/memberRuntimeSummary';
|
import {
|
||||||
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
|
getMemberLaunchDiagnosticsErrorMessage,
|
||||||
|
hasMemberLaunchDiagnosticsDetails,
|
||||||
|
hasMemberLaunchDiagnosticsError,
|
||||||
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
|
import {
|
||||||
|
getRuntimeMemorySourceLabel,
|
||||||
|
resolveMemberRuntimeSummary,
|
||||||
|
} from '@renderer/utils/memberRuntimeSummary';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import {
|
import {
|
||||||
BarChart3,
|
BarChart3,
|
||||||
|
|
@ -22,6 +31,7 @@ import { buildMemberActivityEntries } from './memberActivityEntries';
|
||||||
import { MemberDetailHeader } from './MemberDetailHeader';
|
import { MemberDetailHeader } from './MemberDetailHeader';
|
||||||
import { MemberDetailStats } from './MemberDetailStats';
|
import { MemberDetailStats } from './MemberDetailStats';
|
||||||
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
import { type MemberActivityFilter, type MemberDetailTab } from './memberDetailTypes';
|
||||||
|
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||||
import { MemberLogsTab } from './MemberLogsTab';
|
import { MemberLogsTab } from './MemberLogsTab';
|
||||||
import { MemberMessagesTab } from './MemberMessagesTab';
|
import { MemberMessagesTab } from './MemberMessagesTab';
|
||||||
import { MemberStatsTab } from './MemberStatsTab';
|
import { MemberStatsTab } from './MemberStatsTab';
|
||||||
|
|
@ -50,6 +60,7 @@ interface MemberDetailDialogProps {
|
||||||
leadActivity?: LeadActivityState;
|
leadActivity?: LeadActivityState;
|
||||||
spawnEntry?: MemberSpawnStatusEntry;
|
spawnEntry?: MemberSpawnStatusEntry;
|
||||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
|
runtimeRunId?: string | null;
|
||||||
launchParams?: TeamLaunchParams;
|
launchParams?: TeamLaunchParams;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onSendMessage: () => void;
|
onSendMessage: () => void;
|
||||||
|
|
@ -76,6 +87,7 @@ export const MemberDetailDialog = ({
|
||||||
leadActivity,
|
leadActivity,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
|
runtimeRunId,
|
||||||
launchParams,
|
launchParams,
|
||||||
onClose,
|
onClose,
|
||||||
onSendMessage,
|
onSendMessage,
|
||||||
|
|
@ -128,10 +140,31 @@ export const MemberDetailDialog = ({
|
||||||
: undefined,
|
: undefined,
|
||||||
[launchParams, member, runtimeEntry, spawnEntry]
|
[launchParams, member, runtimeEntry, spawnEntry]
|
||||||
);
|
);
|
||||||
|
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
||||||
const restartInFlight =
|
const restartInFlight =
|
||||||
spawnEntry?.launchState === 'starting' ||
|
spawnEntry?.launchState === 'starting' ||
|
||||||
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
|
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
|
||||||
spawnEntry?.launchState === 'runtime_pending_permission';
|
spawnEntry?.launchState === 'runtime_pending_permission';
|
||||||
|
const launchDiagnosticsPayload = useMemo(
|
||||||
|
() =>
|
||||||
|
member
|
||||||
|
? buildMemberLaunchDiagnosticsPayload({
|
||||||
|
teamName,
|
||||||
|
runId: runtimeRunId,
|
||||||
|
memberName: member.name,
|
||||||
|
spawnEntry,
|
||||||
|
runtimeEntry,
|
||||||
|
})
|
||||||
|
: null,
|
||||||
|
[member, runtimeEntry, runtimeRunId, spawnEntry, teamName]
|
||||||
|
);
|
||||||
|
const showCopyDiagnostics =
|
||||||
|
launchDiagnosticsPayload != null &&
|
||||||
|
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||||
|
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||||
|
const launchErrorMessage = launchDiagnosticsPayload
|
||||||
|
? getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload)
|
||||||
|
: undefined;
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !member) {
|
if (!open || !member) {
|
||||||
|
|
@ -251,10 +284,23 @@ export const MemberDetailDialog = ({
|
||||||
<DialogFooter>
|
<DialogFooter>
|
||||||
{restartError ? (
|
{restartError ? (
|
||||||
<div className="mr-auto text-xs text-red-400">{restartError}</div>
|
<div className="mr-auto text-xs text-red-400">{restartError}</div>
|
||||||
|
) : launchErrorMessage ? (
|
||||||
|
<div className="mr-auto flex min-w-0 items-center gap-2 text-xs text-red-400">
|
||||||
|
<span className="min-w-0 truncate" title={launchErrorMessage}>
|
||||||
|
{launchErrorMessage}
|
||||||
|
</span>
|
||||||
|
{launchDiagnosticsPayload && showCopyDiagnostics ? (
|
||||||
|
<MemberLaunchDiagnosticsButton
|
||||||
|
payload={launchDiagnosticsPayload}
|
||||||
|
label="Copy diagnostics"
|
||||||
|
className="h-auto shrink-0 gap-1.5 px-2 py-1 text-red-300 hover:bg-red-500/10 hover:text-red-200"
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
) : runtimeEntry?.pid ? (
|
) : runtimeEntry?.pid ? (
|
||||||
<div className="mr-auto text-xs text-[var(--color-text-muted)]">
|
<div className="mr-auto text-xs text-[var(--color-text-muted)]">
|
||||||
PID {runtimeEntry.pid}
|
PID {runtimeEntry.pid}
|
||||||
{runtimeEntry.pidSource ? ` · ${runtimeEntry.pidSource}` : ''}
|
{memorySourceLabel ? ` · ${memorySourceLabel}` : ''}
|
||||||
</div>
|
</div>
|
||||||
) : (
|
) : (
|
||||||
<div className="mr-auto" />
|
<div className="mr-auto" />
|
||||||
|
|
|
||||||
|
|
@ -22,6 +22,12 @@ import {
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
import {
|
||||||
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
|
getMemberLaunchDiagnosticsErrorMessage,
|
||||||
|
hasMemberLaunchDiagnosticsDetails,
|
||||||
|
hasMemberLaunchDiagnosticsError,
|
||||||
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||||
import { ExternalLink } from 'lucide-react';
|
import { ExternalLink } from 'lucide-react';
|
||||||
import { useShallow } from 'zustand/react/shallow';
|
import { useShallow } from 'zustand/react/shallow';
|
||||||
|
|
@ -29,6 +35,7 @@ import { useShallow } from 'zustand/react/shallow';
|
||||||
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
import { getLaunchJoinMilestonesFromMembers, getLaunchJoinState } from '../provisioningSteps';
|
||||||
|
|
||||||
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
import { CurrentTaskIndicator } from './CurrentTaskIndicator';
|
||||||
|
import { MemberLaunchDiagnosticsButton } from './MemberLaunchDiagnosticsButton';
|
||||||
|
|
||||||
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
import type { LeadActivityState, TeamTaskWithKanban } from '@shared/types';
|
||||||
|
|
||||||
|
|
@ -68,6 +75,7 @@ export const MemberHoverCard = ({
|
||||||
memberSpawnSnapshot,
|
memberSpawnSnapshot,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
spawnEntry,
|
spawnEntry,
|
||||||
|
runtimeRunId,
|
||||||
runtimeEntry,
|
runtimeEntry,
|
||||||
leadActivity,
|
leadActivity,
|
||||||
} = useStore(
|
} = useStore(
|
||||||
|
|
@ -90,8 +98,11 @@ export const MemberHoverCard = ({
|
||||||
spawnEntry: effectiveTeamName
|
spawnEntry: effectiveTeamName
|
||||||
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
|
? s.memberSpawnStatusesByTeam[effectiveTeamName]?.[name]
|
||||||
: undefined,
|
: undefined,
|
||||||
|
runtimeRunId: effectiveTeamName
|
||||||
|
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.runId
|
||||||
|
: undefined,
|
||||||
runtimeEntry: effectiveTeamName
|
runtimeEntry: effectiveTeamName
|
||||||
? s.teamAgentRuntimeByTeam[effectiveTeamName]?.members[name]
|
? s.teamAgentRuntimeByTeam?.[effectiveTeamName]?.members[name]
|
||||||
: undefined,
|
: undefined,
|
||||||
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
|
leadActivity: effectiveTeamName ? s.leadActivityByTeam[effectiveTeamName] : undefined,
|
||||||
}))
|
}))
|
||||||
|
|
@ -143,6 +154,17 @@ export const MemberHoverCard = ({
|
||||||
launchVisualState === 'stale_runtime'
|
launchVisualState === 'stale_runtime'
|
||||||
? (launchStatusLabel ?? presenceLabel)
|
? (launchStatusLabel ?? presenceLabel)
|
||||||
: presenceLabel;
|
: presenceLabel;
|
||||||
|
const launchDiagnosticsPayload = buildMemberLaunchDiagnosticsPayload({
|
||||||
|
teamName: effectiveTeamName,
|
||||||
|
runId: runtimeRunId ?? memberSpawnSnapshot?.runId ?? progress?.runId,
|
||||||
|
memberName: member.name,
|
||||||
|
spawnEntry,
|
||||||
|
runtimeEntry,
|
||||||
|
});
|
||||||
|
const launchErrorMessage = getMemberLaunchDiagnosticsErrorMessage(launchDiagnosticsPayload);
|
||||||
|
const showCopyDiagnostics =
|
||||||
|
hasMemberLaunchDiagnosticsError(launchDiagnosticsPayload) &&
|
||||||
|
hasMemberLaunchDiagnosticsDetails(launchDiagnosticsPayload);
|
||||||
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
const currentTask: TeamTaskWithKanban | null = member.currentTaskId
|
||||||
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
? (tasks.find((t) => t.id === member.currentTaskId) ?? null)
|
||||||
: null;
|
: null;
|
||||||
|
|
@ -236,18 +258,33 @@ export const MemberHoverCard = ({
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Open profile button */}
|
{launchErrorMessage ? (
|
||||||
<button
|
<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">
|
||||||
type="button"
|
<span className="min-w-0 flex-1 truncate" title={launchErrorMessage}>
|
||||||
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)]"
|
{launchErrorMessage}
|
||||||
onClick={(e) => {
|
</span>
|
||||||
e.stopPropagation();
|
{showCopyDiagnostics ? (
|
||||||
openMemberProfile(member.name);
|
<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"
|
||||||
<ExternalLink size={12} />
|
/>
|
||||||
Open profile
|
) : null}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
</HoverCardContent>
|
</HoverCardContent>
|
||||||
</HoverCard>
|
</HoverCard>
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,60 @@
|
||||||
|
import { useState } from 'react';
|
||||||
|
|
||||||
|
import { Button } from '@renderer/components/ui/button';
|
||||||
|
import { Tooltip, TooltipContent, TooltipTrigger } from '@renderer/components/ui/tooltip';
|
||||||
|
import {
|
||||||
|
formatMemberLaunchDiagnosticsPayload,
|
||||||
|
type MemberLaunchDiagnosticsPayload,
|
||||||
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
|
import { Check, ClipboardList } from 'lucide-react';
|
||||||
|
|
||||||
|
interface MemberLaunchDiagnosticsButtonProps {
|
||||||
|
payload: MemberLaunchDiagnosticsPayload;
|
||||||
|
label?: string;
|
||||||
|
className?: string;
|
||||||
|
size?: 'icon' | 'sm';
|
||||||
|
}
|
||||||
|
|
||||||
|
export const MemberLaunchDiagnosticsButton = ({
|
||||||
|
payload,
|
||||||
|
label,
|
||||||
|
className,
|
||||||
|
size = label ? 'sm' : 'icon',
|
||||||
|
}: MemberLaunchDiagnosticsButtonProps): React.JSX.Element => {
|
||||||
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
|
const copyDiagnostics = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(formatMemberLaunchDiagnosticsPayload(payload));
|
||||||
|
setCopied(true);
|
||||||
|
window.setTimeout(() => setCopied(false), 1500);
|
||||||
|
} catch {
|
||||||
|
setCopied(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const icon = copied ? <Check size={13} /> : <ClipboardList size={13} />;
|
||||||
|
const tooltip = copied ? 'Diagnostics copied' : 'Copy diagnostics';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
type="button"
|
||||||
|
variant="ghost"
|
||||||
|
size={size}
|
||||||
|
className={className}
|
||||||
|
title={tooltip}
|
||||||
|
aria-label={tooltip}
|
||||||
|
onClick={copyDiagnostics}
|
||||||
|
>
|
||||||
|
{icon}
|
||||||
|
{label ? <span>{label}</span> : null}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent side="bottom">{tooltip}</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
@ -23,6 +23,7 @@ interface MemberListProps {
|
||||||
pendingRepliesByMember?: Record<string, number>;
|
pendingRepliesByMember?: Record<string, number>;
|
||||||
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
|
memberSpawnStatuses?: Map<string, MemberSpawnStatusEntry>;
|
||||||
memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>;
|
memberRuntimeEntries?: Map<string, TeamAgentRuntimeEntry>;
|
||||||
|
runtimeRunId?: string | null;
|
||||||
isLaunchSettling?: boolean;
|
isLaunchSettling?: boolean;
|
||||||
isTeamAlive?: boolean;
|
isTeamAlive?: boolean;
|
||||||
isTeamProvisioning?: boolean;
|
isTeamProvisioning?: boolean;
|
||||||
|
|
@ -192,20 +193,34 @@ function areMemberRuntimeEntriesEquivalent(
|
||||||
if (left.size !== right.size) return false;
|
if (left.size !== right.size) return false;
|
||||||
for (const [key, leftEntry] of left) {
|
for (const [key, leftEntry] of left) {
|
||||||
const rightEntry = right.get(key);
|
const rightEntry = right.get(key);
|
||||||
|
const leftDiagnostics = leftEntry.diagnostics ?? [];
|
||||||
|
const rightDiagnostics = rightEntry?.diagnostics ?? [];
|
||||||
if (
|
if (
|
||||||
leftEntry.memberName !== rightEntry?.memberName ||
|
leftEntry.memberName !== rightEntry?.memberName ||
|
||||||
leftEntry.alive !== rightEntry?.alive ||
|
leftEntry.alive !== rightEntry?.alive ||
|
||||||
leftEntry.restartable !== rightEntry?.restartable ||
|
leftEntry.restartable !== rightEntry?.restartable ||
|
||||||
leftEntry.backendType !== rightEntry?.backendType ||
|
leftEntry.backendType !== rightEntry?.backendType ||
|
||||||
|
leftEntry.providerId !== rightEntry?.providerId ||
|
||||||
|
leftEntry.providerBackendId !== rightEntry?.providerBackendId ||
|
||||||
|
leftEntry.laneId !== rightEntry?.laneId ||
|
||||||
|
leftEntry.laneKind !== rightEntry?.laneKind ||
|
||||||
leftEntry.pid !== rightEntry?.pid ||
|
leftEntry.pid !== rightEntry?.pid ||
|
||||||
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
|
leftEntry.runtimeModel !== rightEntry?.runtimeModel ||
|
||||||
leftEntry.rssBytes !== rightEntry?.rssBytes ||
|
leftEntry.rssBytes !== rightEntry?.rssBytes ||
|
||||||
leftEntry.livenessKind !== rightEntry?.livenessKind ||
|
leftEntry.livenessKind !== rightEntry?.livenessKind ||
|
||||||
leftEntry.pidSource !== rightEntry?.pidSource ||
|
leftEntry.pidSource !== rightEntry?.pidSource ||
|
||||||
|
leftEntry.processCommand !== rightEntry?.processCommand ||
|
||||||
|
leftEntry.paneId !== rightEntry?.paneId ||
|
||||||
|
leftEntry.panePid !== rightEntry?.panePid ||
|
||||||
leftEntry.paneCurrentCommand !== rightEntry?.paneCurrentCommand ||
|
leftEntry.paneCurrentCommand !== rightEntry?.paneCurrentCommand ||
|
||||||
|
leftEntry.runtimePid !== rightEntry?.runtimePid ||
|
||||||
|
leftEntry.runtimeSessionId !== rightEntry?.runtimeSessionId ||
|
||||||
leftEntry.runtimeDiagnostic !== rightEntry?.runtimeDiagnostic ||
|
leftEntry.runtimeDiagnostic !== rightEntry?.runtimeDiagnostic ||
|
||||||
leftEntry.runtimeDiagnosticSeverity !== rightEntry?.runtimeDiagnosticSeverity ||
|
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;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
@ -224,6 +239,7 @@ function areMemberListPropsEqual(
|
||||||
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
|
arePendingRepliesEquivalent(prev.pendingRepliesByMember, next.pendingRepliesByMember) &&
|
||||||
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
|
areMemberSpawnStatusesEquivalent(prev.memberSpawnStatuses, next.memberSpawnStatuses) &&
|
||||||
areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) &&
|
areMemberRuntimeEntriesEquivalent(prev.memberRuntimeEntries, next.memberRuntimeEntries) &&
|
||||||
|
prev.runtimeRunId === next.runtimeRunId &&
|
||||||
prev.isLaunchSettling === next.isLaunchSettling &&
|
prev.isLaunchSettling === next.isLaunchSettling &&
|
||||||
prev.isTeamAlive === next.isTeamAlive &&
|
prev.isTeamAlive === next.isTeamAlive &&
|
||||||
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
prev.isTeamProvisioning === next.isTeamProvisioning &&
|
||||||
|
|
@ -239,6 +255,7 @@ export const MemberList = memo(function MemberList({
|
||||||
pendingRepliesByMember,
|
pendingRepliesByMember,
|
||||||
memberSpawnStatuses,
|
memberSpawnStatuses,
|
||||||
memberRuntimeEntries,
|
memberRuntimeEntries,
|
||||||
|
runtimeRunId,
|
||||||
isLaunchSettling,
|
isLaunchSettling,
|
||||||
isTeamAlive,
|
isTeamAlive,
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
|
|
@ -342,7 +359,9 @@ export const MemberList = memo(function MemberList({
|
||||||
isRemoved ? undefined : runtimeEntry
|
isRemoved ? undefined : runtimeEntry
|
||||||
)}
|
)}
|
||||||
runtimeEntry={isRemoved ? undefined : runtimeEntry}
|
runtimeEntry={isRemoved ? undefined : runtimeEntry}
|
||||||
|
runtimeRunId={isRemoved ? undefined : runtimeRunId}
|
||||||
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
spawnStatus={isRemoved ? undefined : spawnEntry?.status}
|
||||||
|
spawnEntry={isRemoved ? undefined : spawnEntry}
|
||||||
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
|
spawnError={isRemoved ? undefined : (spawnEntry?.error ?? spawnEntry?.hardFailureReason)}
|
||||||
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
spawnLivenessSource={isRemoved ? undefined : spawnEntry?.livenessSource}
|
||||||
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
spawnLaunchState={isRemoved ? undefined : spawnEntry?.launchState}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import { MembersEditorSection } from './MembersEditorSection';
|
||||||
|
|
||||||
import type { MemberDraft } from './membersEditorTypes';
|
import type { MemberDraft } from './membersEditorTypes';
|
||||||
import type { MentionSuggestion } from '@renderer/types/mention';
|
import type { MentionSuggestion } from '@renderer/types/mention';
|
||||||
import type { EffortLevel, TeamFastMode, TeamProviderId } from '@shared/types';
|
import type { EffortLevel, TeamProviderId } from '@shared/types';
|
||||||
|
|
||||||
interface TeamRosterEditorSectionProps {
|
interface TeamRosterEditorSectionProps {
|
||||||
members: MemberDraft[];
|
members: MemberDraft[];
|
||||||
|
|
@ -31,13 +31,10 @@ interface TeamRosterEditorSectionProps {
|
||||||
providerId: TeamProviderId;
|
providerId: TeamProviderId;
|
||||||
model: string;
|
model: string;
|
||||||
effort?: EffortLevel;
|
effort?: EffortLevel;
|
||||||
fastMode?: TeamFastMode;
|
|
||||||
providerFastModeDefault?: boolean;
|
|
||||||
limitContext: boolean;
|
limitContext: boolean;
|
||||||
onProviderChange: (providerId: TeamProviderId) => void;
|
onProviderChange: (providerId: TeamProviderId) => void;
|
||||||
onModelChange: (model: string) => void;
|
onModelChange: (model: string) => void;
|
||||||
onEffortChange: (effort: string) => void;
|
onEffortChange: (effort: string) => void;
|
||||||
onFastModeChange?: (fastMode: TeamFastMode) => void;
|
|
||||||
onLimitContextChange: (value: boolean) => void;
|
onLimitContextChange: (value: boolean) => void;
|
||||||
syncModelsWithTeammates: boolean;
|
syncModelsWithTeammates: boolean;
|
||||||
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
onSyncModelsWithTeammatesChange: (value: boolean) => void;
|
||||||
|
|
@ -45,7 +42,6 @@ interface TeamRosterEditorSectionProps {
|
||||||
headerBottom?: React.ReactNode;
|
headerBottom?: React.ReactNode;
|
||||||
softDeleteMembers?: boolean;
|
softDeleteMembers?: boolean;
|
||||||
leadWarningText?: string | null;
|
leadWarningText?: string | null;
|
||||||
leadFastModeNotice?: string | null;
|
|
||||||
memberWarningById?: Record<string, string | null | undefined>;
|
memberWarningById?: Record<string, string | null | undefined>;
|
||||||
disableGeminiOption?: boolean;
|
disableGeminiOption?: boolean;
|
||||||
leadModelIssueText?: string | null;
|
leadModelIssueText?: string | null;
|
||||||
|
|
@ -79,13 +75,10 @@ export const TeamRosterEditorSection = ({
|
||||||
providerId,
|
providerId,
|
||||||
model,
|
model,
|
||||||
effort,
|
effort,
|
||||||
fastMode,
|
|
||||||
providerFastModeDefault,
|
|
||||||
limitContext,
|
limitContext,
|
||||||
onProviderChange,
|
onProviderChange,
|
||||||
onModelChange,
|
onModelChange,
|
||||||
onEffortChange,
|
onEffortChange,
|
||||||
onFastModeChange,
|
|
||||||
onLimitContextChange,
|
onLimitContextChange,
|
||||||
syncModelsWithTeammates,
|
syncModelsWithTeammates,
|
||||||
onSyncModelsWithTeammatesChange,
|
onSyncModelsWithTeammatesChange,
|
||||||
|
|
@ -93,7 +86,6 @@ export const TeamRosterEditorSection = ({
|
||||||
headerBottom,
|
headerBottom,
|
||||||
softDeleteMembers = false,
|
softDeleteMembers = false,
|
||||||
leadWarningText,
|
leadWarningText,
|
||||||
leadFastModeNotice,
|
|
||||||
memberWarningById,
|
memberWarningById,
|
||||||
disableGeminiOption = false,
|
disableGeminiOption = false,
|
||||||
leadModelIssueText,
|
leadModelIssueText,
|
||||||
|
|
@ -138,18 +130,14 @@ export const TeamRosterEditorSection = ({
|
||||||
providerId={providerId}
|
providerId={providerId}
|
||||||
model={model}
|
model={model}
|
||||||
effort={effort}
|
effort={effort}
|
||||||
fastMode={fastMode}
|
|
||||||
providerFastModeDefault={providerFastModeDefault}
|
|
||||||
limitContext={limitContext}
|
limitContext={limitContext}
|
||||||
onProviderChange={onProviderChange}
|
onProviderChange={onProviderChange}
|
||||||
onModelChange={onModelChange}
|
onModelChange={onModelChange}
|
||||||
onEffortChange={onEffortChange}
|
onEffortChange={onEffortChange}
|
||||||
onFastModeChange={onFastModeChange}
|
|
||||||
onLimitContextChange={onLimitContextChange}
|
onLimitContextChange={onLimitContextChange}
|
||||||
syncModelsWithTeammates={syncModelsWithTeammates}
|
syncModelsWithTeammates={syncModelsWithTeammates}
|
||||||
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
onSyncModelsWithTeammatesChange={onSyncModelsWithTeammatesChange}
|
||||||
warningText={leadWarningText}
|
warningText={leadWarningText}
|
||||||
fastModeNotice={leadFastModeNotice}
|
|
||||||
disableGeminiOption={disableGeminiOption}
|
disableGeminiOption={disableGeminiOption}
|
||||||
modelIssueText={leadModelIssueText}
|
modelIssueText={leadModelIssueText}
|
||||||
/>
|
/>
|
||||||
|
|
|
||||||
|
|
@ -63,6 +63,10 @@ function isFailedSpawnEntry(entry: MemberSpawnStatusEntry | undefined): boolean
|
||||||
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
|
return entry?.launchState === 'failed_to_start' || entry?.status === 'error';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function isStrongRuntimeProcessSpawnEntry(entry: MemberSpawnStatusEntry): boolean {
|
||||||
|
return entry.runtimeAlive === true && entry.livenessKind === 'runtime_process';
|
||||||
|
}
|
||||||
|
|
||||||
function shouldPreferSnapshotEntryOverLive(
|
function shouldPreferSnapshotEntryOverLive(
|
||||||
liveEntry: MemberSpawnStatusEntry | undefined,
|
liveEntry: MemberSpawnStatusEntry | undefined,
|
||||||
snapshotEntry: MemberSpawnStatusEntry | undefined,
|
snapshotEntry: MemberSpawnStatusEntry | undefined,
|
||||||
|
|
@ -127,11 +131,12 @@ function summarizeLiveLaunchJoinMilestones(params: {
|
||||||
heartbeatConfirmedCount += 1;
|
heartbeatConfirmedCount += 1;
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (
|
if (entry.launchState === 'runtime_pending_permission') {
|
||||||
entry.launchState === 'runtime_pending_bootstrap' ||
|
pendingSpawnCount += 1;
|
||||||
entry.launchState === 'runtime_pending_permission'
|
continue;
|
||||||
) {
|
}
|
||||||
if (entry.runtimeAlive === true && entry.livenessKind !== 'shell_only') {
|
if (entry.launchState === 'runtime_pending_bootstrap') {
|
||||||
|
if (isStrongRuntimeProcessSpawnEntry(entry)) {
|
||||||
processOnlyAliveCount += 1;
|
processOnlyAliveCount += 1;
|
||||||
} else {
|
} else {
|
||||||
pendingSpawnCount += 1;
|
pendingSpawnCount += 1;
|
||||||
|
|
@ -196,15 +201,12 @@ export function getLaunchJoinMilestonesFromMembers({
|
||||||
});
|
});
|
||||||
|
|
||||||
if (snapshotSummary) {
|
if (snapshotSummary) {
|
||||||
|
const snapshotProcessOnlyAliveCount = snapshotSummary.runtimeProcessPendingCount ?? 0;
|
||||||
const snapshotMilestones = {
|
const snapshotMilestones = {
|
||||||
expectedTeammateCount,
|
expectedTeammateCount,
|
||||||
heartbeatConfirmedCount: snapshotSummary.confirmedCount,
|
heartbeatConfirmedCount: snapshotSummary.confirmedCount,
|
||||||
processOnlyAliveCount:
|
processOnlyAliveCount: snapshotProcessOnlyAliveCount,
|
||||||
snapshotSummary.runtimeProcessPendingCount ?? snapshotSummary.runtimeAlivePendingCount,
|
pendingSpawnCount: Math.max(0, snapshotSummary.pendingCount - snapshotProcessOnlyAliveCount),
|
||||||
pendingSpawnCount: Math.max(
|
|
||||||
0,
|
|
||||||
snapshotSummary.pendingCount - snapshotSummary.runtimeAlivePendingCount
|
|
||||||
),
|
|
||||||
failedSpawnCount: snapshotSummary.failedCount,
|
failedSpawnCount: snapshotSummary.failedCount,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -374,6 +374,19 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
set,
|
set,
|
||||||
get
|
get
|
||||||
) => {
|
) => {
|
||||||
|
const addMatchingReviewPathAliases = (
|
||||||
|
aliases: Set<string>,
|
||||||
|
filePath: string,
|
||||||
|
canonicalFilePath: string,
|
||||||
|
record: Record<string, unknown>
|
||||||
|
): void => {
|
||||||
|
for (const key of Object.keys(record)) {
|
||||||
|
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
|
||||||
|
aliases.add(key);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
const buildResolvedFileInvalidation = (
|
const buildResolvedFileInvalidation = (
|
||||||
s: ChangeReviewSlice,
|
s: ChangeReviewSlice,
|
||||||
filePath: string
|
filePath: string
|
||||||
|
|
@ -388,17 +401,10 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
const existing = findReviewFileByPath(s.activeChangeSet?.files, filePath);
|
const existing = findReviewFileByPath(s.activeChangeSet?.files, filePath);
|
||||||
const canonicalFilePath = existing?.filePath ?? filePath;
|
const canonicalFilePath = existing?.filePath ?? filePath;
|
||||||
const aliases = new Set([filePath, canonicalFilePath]);
|
const aliases = new Set([filePath, canonicalFilePath]);
|
||||||
const addMatchingAliases = (record: Record<string, unknown>): void => {
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
|
||||||
for (const key of Object.keys(record)) {
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
|
||||||
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentsLoading);
|
||||||
aliases.add(key);
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContentVersionByPath);
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
addMatchingAliases(s.fileChunkCounts);
|
|
||||||
addMatchingAliases(s.fileContents);
|
|
||||||
addMatchingAliases(s.fileContentsLoading);
|
|
||||||
addMatchingAliases(s.fileContentVersionByPath);
|
|
||||||
const nextFileChunkCounts = { ...s.fileChunkCounts };
|
const nextFileChunkCounts = { ...s.fileChunkCounts };
|
||||||
for (const alias of aliases) delete nextFileChunkCounts[alias];
|
for (const alias of aliases) delete nextFileChunkCounts[alias];
|
||||||
|
|
||||||
|
|
@ -1461,18 +1467,21 @@ export const createChangeReviewSlice: StateCreator<AppState, [], [], ChangeRevie
|
||||||
await api.review.saveEditedFile(canonicalFilePath, content, projectPath);
|
await api.review.saveEditedFile(canonicalFilePath, content, projectPath);
|
||||||
set((s) => {
|
set((s) => {
|
||||||
const aliases = new Set([filePath, canonicalFilePath]);
|
const aliases = new Set([filePath, canonicalFilePath]);
|
||||||
const addMatchingAliases = (record: Record<string, unknown>): void => {
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.editedContents);
|
||||||
for (const key of Object.keys(record)) {
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileChunkCounts);
|
||||||
if (reviewPathsEqual(key, filePath) || reviewPathsEqual(key, canonicalFilePath)) {
|
addMatchingReviewPathAliases(
|
||||||
aliases.add(key);
|
aliases,
|
||||||
}
|
filePath,
|
||||||
}
|
canonicalFilePath,
|
||||||
};
|
s.hunkContextHashesByFile
|
||||||
addMatchingAliases(s.editedContents);
|
);
|
||||||
addMatchingAliases(s.fileChunkCounts);
|
addMatchingReviewPathAliases(
|
||||||
addMatchingAliases(s.hunkContextHashesByFile);
|
aliases,
|
||||||
addMatchingAliases(s.reviewExternalChangesByFile);
|
filePath,
|
||||||
addMatchingAliases(s.fileContents);
|
canonicalFilePath,
|
||||||
|
s.reviewExternalChangesByFile
|
||||||
|
);
|
||||||
|
addMatchingReviewPathAliases(aliases, filePath, canonicalFilePath, s.fileContents);
|
||||||
|
|
||||||
const nextEdited = { ...s.editedContents };
|
const nextEdited = { ...s.editedContents };
|
||||||
for (const alias of aliases) delete nextEdited[alias];
|
for (const alias of aliases) delete nextEdited[alias];
|
||||||
|
|
|
||||||
|
|
@ -810,20 +810,34 @@ function areTeamAgentRuntimeEntriesEqual(
|
||||||
): boolean {
|
): boolean {
|
||||||
if (left === right) return true;
|
if (left === right) return true;
|
||||||
if (!left || !right) return left === right;
|
if (!left || !right) return left === right;
|
||||||
|
const leftDiagnostics = left.diagnostics ?? [];
|
||||||
|
const rightDiagnostics = right.diagnostics ?? [];
|
||||||
return (
|
return (
|
||||||
left.memberName === right.memberName &&
|
left.memberName === right.memberName &&
|
||||||
left.alive === right.alive &&
|
left.alive === right.alive &&
|
||||||
left.restartable === right.restartable &&
|
left.restartable === right.restartable &&
|
||||||
left.backendType === right.backendType &&
|
left.backendType === right.backendType &&
|
||||||
|
left.providerId === right.providerId &&
|
||||||
|
left.providerBackendId === right.providerBackendId &&
|
||||||
|
left.laneId === right.laneId &&
|
||||||
|
left.laneKind === right.laneKind &&
|
||||||
left.pid === right.pid &&
|
left.pid === right.pid &&
|
||||||
left.runtimeModel === right.runtimeModel &&
|
left.runtimeModel === right.runtimeModel &&
|
||||||
left.rssBytes === right.rssBytes &&
|
left.rssBytes === right.rssBytes &&
|
||||||
left.livenessKind === right.livenessKind &&
|
left.livenessKind === right.livenessKind &&
|
||||||
left.pidSource === right.pidSource &&
|
left.pidSource === right.pidSource &&
|
||||||
|
left.processCommand === right.processCommand &&
|
||||||
|
left.paneId === right.paneId &&
|
||||||
|
left.panePid === right.panePid &&
|
||||||
left.paneCurrentCommand === right.paneCurrentCommand &&
|
left.paneCurrentCommand === right.paneCurrentCommand &&
|
||||||
|
left.runtimePid === right.runtimePid &&
|
||||||
|
left.runtimeSessionId === right.runtimeSessionId &&
|
||||||
left.runtimeDiagnostic === right.runtimeDiagnostic &&
|
left.runtimeDiagnostic === right.runtimeDiagnostic &&
|
||||||
left.runtimeDiagnosticSeverity === right.runtimeDiagnosticSeverity &&
|
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'
|
runtimeEntry?.livenessKind === 'not_found'
|
||||||
) {
|
) {
|
||||||
launchVisualState = 'stale_runtime';
|
launchVisualState = 'stale_runtime';
|
||||||
} else if (
|
|
||||||
spawnLaunchState === 'runtime_pending_bootstrap' &&
|
|
||||||
(runtimeEntry?.livenessKind === 'runtime_process' ||
|
|
||||||
(spawnStatus === 'online' && spawnRuntimeAlive === true))
|
|
||||||
) {
|
|
||||||
launchVisualState = 'runtime_pending';
|
|
||||||
} else if (
|
} else if (
|
||||||
isLaunchStillStarting(
|
isLaunchStillStarting(
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
|
@ -671,6 +665,12 @@ export function buildMemberLaunchPresentation({
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
|
launchVisualState = spawnStatus === 'spawning' ? 'spawning' : 'waiting';
|
||||||
|
} else if (
|
||||||
|
spawnLaunchState === 'runtime_pending_bootstrap' &&
|
||||||
|
(runtimeEntry?.livenessKind === 'runtime_process' ||
|
||||||
|
(spawnStatus === 'online' && spawnRuntimeAlive === true))
|
||||||
|
) {
|
||||||
|
launchVisualState = 'runtime_pending';
|
||||||
} else if (
|
} else if (
|
||||||
isLaunchSettling &&
|
isLaunchSettling &&
|
||||||
spawnStatus === 'online' &&
|
spawnStatus === 'online' &&
|
||||||
|
|
@ -681,15 +681,19 @@ export function buildMemberLaunchPresentation({
|
||||||
}
|
}
|
||||||
|
|
||||||
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
|
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
|
||||||
const displayPresenceLabel =
|
const shouldShowLaunchStatusAsPresence =
|
||||||
launchVisualState === 'permission_pending' ||
|
launchVisualState === 'permission_pending' ||
|
||||||
launchVisualState === 'runtime_pending' ||
|
launchVisualState === 'runtime_pending' ||
|
||||||
launchVisualState === 'shell_only' ||
|
launchVisualState === 'shell_only' ||
|
||||||
launchVisualState === 'runtime_candidate' ||
|
launchVisualState === 'runtime_candidate' ||
|
||||||
launchVisualState === 'registered_only' ||
|
launchVisualState === 'registered_only' ||
|
||||||
launchVisualState === 'stale_runtime'
|
launchVisualState === 'stale_runtime';
|
||||||
? (launchStatusLabel ?? presenceLabel)
|
const displayPresenceLabel =
|
||||||
: presenceLabel;
|
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||||
|
? runtimeAdvisoryLabel
|
||||||
|
: shouldShowLaunchStatusAsPresence
|
||||||
|
? (launchStatusLabel ?? presenceLabel)
|
||||||
|
: presenceLabel;
|
||||||
const spawnBadgeLabel =
|
const spawnBadgeLabel =
|
||||||
spawnStatus && spawnStatus !== 'online'
|
spawnStatus && spawnStatus !== 'online'
|
||||||
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
||||||
|
|
|
||||||
189
src/renderer/utils/memberLaunchDiagnostics.ts
Normal file
189
src/renderer/utils/memberLaunchDiagnostics.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
||||||
|
import type {
|
||||||
|
MemberLaunchState,
|
||||||
|
MemberSpawnLivenessSource,
|
||||||
|
MemberSpawnStatus,
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
|
TeamAgentRuntimeDiagnosticSeverity,
|
||||||
|
TeamAgentRuntimeEntry,
|
||||||
|
TeamAgentRuntimeLivenessKind,
|
||||||
|
TeamAgentRuntimePidSource,
|
||||||
|
} from '@shared/types';
|
||||||
|
|
||||||
|
export interface MemberLaunchDiagnosticsPayload {
|
||||||
|
teamName?: string;
|
||||||
|
runId?: string;
|
||||||
|
memberName: string;
|
||||||
|
launchState?: MemberLaunchState;
|
||||||
|
spawnStatus?: MemberSpawnStatus;
|
||||||
|
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||||
|
livenessSource?: MemberSpawnLivenessSource;
|
||||||
|
pid?: number;
|
||||||
|
pidSource?: TeamAgentRuntimePidSource;
|
||||||
|
paneId?: string;
|
||||||
|
panePid?: number;
|
||||||
|
paneCurrentCommand?: string;
|
||||||
|
processCommand?: string;
|
||||||
|
runtimePid?: number;
|
||||||
|
runtimeSessionId?: string;
|
||||||
|
runtimeDiagnostic?: string;
|
||||||
|
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||||
|
diagnostics?: string[];
|
||||||
|
updatedAt?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAX_DIAGNOSTIC_STRING_LENGTH = 500;
|
||||||
|
const MAX_DIAGNOSTIC_ITEMS = 20;
|
||||||
|
const SECRET_FLAG_PATTERN =
|
||||||
|
/(--(?:api-key|token|password|secret|authorization|auth-token)(?:=|\s+))("[^"]*"|'[^']*'|\S+)/gi;
|
||||||
|
|
||||||
|
function boundedString(
|
||||||
|
value: string | undefined,
|
||||||
|
maxLength = MAX_DIAGNOSTIC_STRING_LENGTH
|
||||||
|
): string | undefined {
|
||||||
|
const trimmed = value?.replace(/\s+/g, ' ').trim();
|
||||||
|
if (!trimmed) return undefined;
|
||||||
|
const redacted = trimmed.replace(SECRET_FLAG_PATTERN, '$1[redacted]');
|
||||||
|
return redacted.length > maxLength
|
||||||
|
? `${redacted.slice(0, Math.max(0, maxLength - 3))}...`
|
||||||
|
: redacted;
|
||||||
|
}
|
||||||
|
|
||||||
|
function boundedNumber(value: number | undefined): number | undefined {
|
||||||
|
return typeof value === 'number' && Number.isFinite(value) && value > 0
|
||||||
|
? Math.trunc(value)
|
||||||
|
: undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
function uniqueDiagnostics(
|
||||||
|
...groups: Array<readonly (string | undefined)[] | undefined>
|
||||||
|
): string[] | undefined {
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const diagnostics: string[] = [];
|
||||||
|
for (const group of groups) {
|
||||||
|
for (const item of group ?? []) {
|
||||||
|
const normalized = boundedString(item);
|
||||||
|
if (!normalized || seen.has(normalized)) continue;
|
||||||
|
seen.add(normalized);
|
||||||
|
diagnostics.push(normalized);
|
||||||
|
if (diagnostics.length >= MAX_DIAGNOSTIC_ITEMS) {
|
||||||
|
return diagnostics;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return diagnostics.length > 0 ? diagnostics : undefined;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function buildMemberLaunchDiagnosticsPayload(params: {
|
||||||
|
teamName?: string | null;
|
||||||
|
runId?: string | null;
|
||||||
|
memberName: string;
|
||||||
|
spawnStatus?: MemberSpawnStatus;
|
||||||
|
launchState?: MemberLaunchState;
|
||||||
|
livenessSource?: MemberSpawnLivenessSource;
|
||||||
|
spawnEntry?: MemberSpawnStatusEntry;
|
||||||
|
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||||
|
}): MemberLaunchDiagnosticsPayload {
|
||||||
|
const spawnEntry = params.spawnEntry;
|
||||||
|
const runtimeEntry = params.runtimeEntry;
|
||||||
|
const runtimeDiagnostic =
|
||||||
|
boundedString(spawnEntry?.runtimeDiagnostic) ??
|
||||||
|
boundedString(runtimeEntry?.runtimeDiagnostic) ??
|
||||||
|
boundedString(spawnEntry?.hardFailureReason) ??
|
||||||
|
boundedString(spawnEntry?.error);
|
||||||
|
const diagnostics = uniqueDiagnostics(
|
||||||
|
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
|
||||||
|
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
|
||||||
|
spawnEntry?.error ? [spawnEntry.error] : undefined,
|
||||||
|
runtimeEntry?.diagnostics
|
||||||
|
);
|
||||||
|
const runId = boundedString(params.runId ?? undefined);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...(params.teamName ? { teamName: params.teamName } : {}),
|
||||||
|
...(runId ? { runId } : {}),
|
||||||
|
memberName: params.memberName,
|
||||||
|
...((spawnEntry?.launchState ?? params.launchState)
|
||||||
|
? { launchState: spawnEntry?.launchState ?? params.launchState }
|
||||||
|
: {}),
|
||||||
|
...((spawnEntry?.status ?? params.spawnStatus)
|
||||||
|
? { spawnStatus: spawnEntry?.status ?? params.spawnStatus }
|
||||||
|
: {}),
|
||||||
|
...((spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind)
|
||||||
|
? { livenessKind: spawnEntry?.livenessKind ?? runtimeEntry?.livenessKind }
|
||||||
|
: {}),
|
||||||
|
...((spawnEntry?.livenessSource ?? params.livenessSource)
|
||||||
|
? { livenessSource: spawnEntry?.livenessSource ?? params.livenessSource }
|
||||||
|
: {}),
|
||||||
|
...(boundedNumber(runtimeEntry?.pid) ? { pid: boundedNumber(runtimeEntry?.pid) } : {}),
|
||||||
|
...(runtimeEntry?.pidSource ? { pidSource: runtimeEntry.pidSource } : {}),
|
||||||
|
...(boundedString(runtimeEntry?.paneId) ? { paneId: boundedString(runtimeEntry?.paneId) } : {}),
|
||||||
|
...(boundedNumber(runtimeEntry?.panePid)
|
||||||
|
? { panePid: boundedNumber(runtimeEntry?.panePid) }
|
||||||
|
: {}),
|
||||||
|
...(boundedString(runtimeEntry?.paneCurrentCommand)
|
||||||
|
? { paneCurrentCommand: boundedString(runtimeEntry?.paneCurrentCommand) }
|
||||||
|
: {}),
|
||||||
|
...(boundedString(runtimeEntry?.processCommand)
|
||||||
|
? { processCommand: boundedString(runtimeEntry?.processCommand) }
|
||||||
|
: {}),
|
||||||
|
...(boundedNumber(runtimeEntry?.runtimePid)
|
||||||
|
? { runtimePid: boundedNumber(runtimeEntry?.runtimePid) }
|
||||||
|
: {}),
|
||||||
|
...(boundedString(runtimeEntry?.runtimeSessionId)
|
||||||
|
? { runtimeSessionId: boundedString(runtimeEntry?.runtimeSessionId) }
|
||||||
|
: {}),
|
||||||
|
...(runtimeDiagnostic ? { runtimeDiagnostic } : {}),
|
||||||
|
...((spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity)
|
||||||
|
? {
|
||||||
|
runtimeDiagnosticSeverity:
|
||||||
|
spawnEntry?.runtimeDiagnosticSeverity ?? runtimeEntry?.runtimeDiagnosticSeverity,
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
...(diagnostics ? { diagnostics } : {}),
|
||||||
|
...(boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt)
|
||||||
|
? { updatedAt: boundedString(spawnEntry?.updatedAt ?? runtimeEntry?.updatedAt) }
|
||||||
|
: {}),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMemberLaunchDiagnosticsDetails(
|
||||||
|
payload: MemberLaunchDiagnosticsPayload
|
||||||
|
): boolean {
|
||||||
|
const weakLiveness =
|
||||||
|
payload.livenessKind === 'runtime_process_candidate' ||
|
||||||
|
payload.livenessKind === 'permission_blocked' ||
|
||||||
|
payload.livenessKind === 'shell_only' ||
|
||||||
|
payload.livenessKind === 'registered_only' ||
|
||||||
|
payload.livenessKind === 'stale_metadata' ||
|
||||||
|
payload.livenessKind === 'not_found';
|
||||||
|
return Boolean(
|
||||||
|
(payload.launchState && payload.launchState !== 'confirmed_alive') ||
|
||||||
|
(payload.spawnStatus && payload.spawnStatus !== 'online') ||
|
||||||
|
weakLiveness ||
|
||||||
|
payload.runtimeDiagnostic ||
|
||||||
|
payload.diagnostics?.length
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function hasMemberLaunchDiagnosticsError(payload: MemberLaunchDiagnosticsPayload): boolean {
|
||||||
|
return Boolean(
|
||||||
|
payload.spawnStatus === 'error' ||
|
||||||
|
payload.launchState === 'failed_to_start' ||
|
||||||
|
payload.runtimeDiagnosticSeverity === 'error'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getMemberLaunchDiagnosticsErrorMessage(
|
||||||
|
payload: MemberLaunchDiagnosticsPayload
|
||||||
|
): string | undefined {
|
||||||
|
if (!hasMemberLaunchDiagnosticsError(payload)) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
return payload.runtimeDiagnostic ?? payload.diagnostics?.[0] ?? 'Launch failed';
|
||||||
|
}
|
||||||
|
|
||||||
|
export function formatMemberLaunchDiagnosticsPayload(
|
||||||
|
payload: MemberLaunchDiagnosticsPayload
|
||||||
|
): string {
|
||||||
|
return JSON.stringify(payload, null, 2);
|
||||||
|
}
|
||||||
|
|
@ -40,6 +40,37 @@ function isMemberLaunchPending(spawnEntry: MemberSpawnStatusEntry | undefined):
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getRuntimeMemorySourceLabel(
|
||||||
|
runtimeEntry: TeamAgentRuntimeEntry | undefined
|
||||||
|
): string | undefined {
|
||||||
|
if (!runtimeEntry?.pidSource) {
|
||||||
|
return undefined;
|
||||||
|
}
|
||||||
|
if (runtimeEntry.pidSource === 'tmux_pane') {
|
||||||
|
return 'RSS source: tmux pane shell';
|
||||||
|
}
|
||||||
|
if (
|
||||||
|
runtimeEntry.providerId === 'opencode' &&
|
||||||
|
runtimeEntry.restartable === false &&
|
||||||
|
runtimeEntry.pidSource === 'opencode_bridge'
|
||||||
|
) {
|
||||||
|
return 'RSS source: shared OpenCode host';
|
||||||
|
}
|
||||||
|
if (runtimeEntry.pidSource === 'tmux_child' || runtimeEntry.pidSource === 'agent_process_table') {
|
||||||
|
return 'RSS source: runtime process';
|
||||||
|
}
|
||||||
|
if (runtimeEntry.pidSource === 'lead_process') {
|
||||||
|
return 'RSS source: lead process';
|
||||||
|
}
|
||||||
|
if (runtimeEntry.pidSource === 'runtime_bootstrap') {
|
||||||
|
return 'RSS source: runtime bootstrap process';
|
||||||
|
}
|
||||||
|
if (runtimeEntry.pidSource === 'persisted_metadata') {
|
||||||
|
return 'RSS source: persisted runtime metadata';
|
||||||
|
}
|
||||||
|
return `PID source: ${runtimeEntry.pidSource}`;
|
||||||
|
}
|
||||||
|
|
||||||
export function resolveMemberRuntimeSummary(
|
export function resolveMemberRuntimeSummary(
|
||||||
member: ResolvedTeamMember,
|
member: ResolvedTeamMember,
|
||||||
launchParams: TeamLaunchParams | undefined,
|
launchParams: TeamLaunchParams | undefined,
|
||||||
|
|
|
||||||
|
|
@ -2,10 +2,10 @@ export function buildPendingRuntimeSummaryCopy(input: {
|
||||||
confirmedCount?: number | null;
|
confirmedCount?: number | null;
|
||||||
expectedMemberCount?: number | null;
|
expectedMemberCount?: number | null;
|
||||||
memberCount?: number | null;
|
memberCount?: number | null;
|
||||||
runtimeAlivePendingCount?: number | null;
|
runtimeProcessPendingCount?: number | null;
|
||||||
includePeriod?: boolean;
|
includePeriod?: boolean;
|
||||||
}): string {
|
}): string {
|
||||||
const pendingCount = input.runtimeAlivePendingCount ?? 0;
|
const pendingCount = input.runtimeProcessPendingCount ?? 0;
|
||||||
if (pendingCount <= 0) {
|
if (pendingCount <= 0) {
|
||||||
return input.includePeriod
|
return input.includePeriod
|
||||||
? 'Last launch is still reconciling.'
|
? 'Last launch is still reconciling.'
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,17 @@ interface FailedSpawnDetail {
|
||||||
reason: string | null;
|
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 {
|
function parseStatusUpdatedAtMs(value: string | undefined): number | null {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
return null;
|
return null;
|
||||||
|
|
@ -126,25 +137,130 @@ function buildAwaitingPermissionPhrase(count: number): string {
|
||||||
: `${count} teammates awaiting permission approval`;
|
: `${count} teammates awaiting permission approval`;
|
||||||
}
|
}
|
||||||
|
|
||||||
function buildPendingDiagnosticPhrase(
|
function getMemberNamesFromSpawnSources(params: {
|
||||||
summary: MemberSpawnStatusesSnapshot['summary'] | undefined,
|
memberSpawnStatuses: MemberSpawnStatusCollection;
|
||||||
fallbackJoiningPhrase: string
|
memberSpawnSnapshotStatuses?: MemberSpawnStatusesSnapshot['statuses'];
|
||||||
): string {
|
}): 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) {
|
if (!summary) {
|
||||||
return fallbackJoiningPhrase;
|
return fallbackJoiningPhrase;
|
||||||
}
|
}
|
||||||
const parts = [
|
const countParts = [
|
||||||
summary.shellOnlyPendingCount ? `${summary.shellOnlyPendingCount} shell-only` : '',
|
formatCountPendingDiagnostic(summary.shellOnlyPendingCount, 'shell-only'),
|
||||||
summary.runtimeProcessPendingCount
|
formatCountPendingDiagnostic(summary.runtimeProcessPendingCount, 'waiting for bootstrap'),
|
||||||
? `${summary.runtimeProcessPendingCount} waiting for bootstrap`
|
formatCountPendingDiagnostic(summary.runtimeCandidatePendingCount, 'process candidates'),
|
||||||
: '',
|
formatCountPendingDiagnostic(summary.permissionPendingCount, 'awaiting permission'),
|
||||||
summary.runtimeCandidatePendingCount
|
formatCountPendingDiagnostic(summary.noRuntimePendingCount, 'no runtime found'),
|
||||||
? `${summary.runtimeCandidatePendingCount} process candidates`
|
|
||||||
: '',
|
|
||||||
summary.permissionPendingCount ? `${summary.permissionPendingCount} awaiting permission` : '',
|
|
||||||
summary.noRuntimePendingCount ? `${summary.noRuntimePendingCount} no runtime found` : '',
|
|
||||||
].filter(Boolean);
|
].filter(Boolean);
|
||||||
return parts.length > 0 ? parts.join(', ') : fallbackJoiningPhrase;
|
return countParts.length > 0 ? countParts.join(', ') : fallbackJoiningPhrase;
|
||||||
}
|
}
|
||||||
|
|
||||||
const ACTIVE_PROVISIONING_STATES = new Set([
|
const ACTIVE_PROVISIONING_STATES = new Set([
|
||||||
|
|
@ -415,7 +531,13 @@ export function buildTeamProvisioningPresentation({
|
||||||
permissionBlockedCount === remainingJoinCount;
|
permissionBlockedCount === remainingJoinCount;
|
||||||
const pendingDetailPhrase = pendingMembersAwaitApproval
|
const pendingDetailPhrase = pendingMembersAwaitApproval
|
||||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||||
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, joiningPhrase);
|
: buildPendingDiagnosticPhrase({
|
||||||
|
summary: memberSpawnSnapshot?.summary,
|
||||||
|
memberSpawnStatuses,
|
||||||
|
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||||
|
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||||
|
fallbackJoiningPhrase: joiningPhrase,
|
||||||
|
});
|
||||||
const readyCompactDetail =
|
const readyCompactDetail =
|
||||||
failedSpawnCount > 0
|
failedSpawnCount > 0
|
||||||
? (failedSpawnCompactDetail ??
|
? (failedSpawnCompactDetail ??
|
||||||
|
|
@ -492,7 +614,13 @@ export function buildTeamProvisioningPresentation({
|
||||||
permissionBlockedCount > 0 &&
|
permissionBlockedCount > 0 &&
|
||||||
permissionBlockedCount === remainingJoinCount
|
permissionBlockedCount === remainingJoinCount
|
||||||
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
? buildAwaitingPermissionPhrase(permissionBlockedCount)
|
||||||
: buildPendingDiagnosticPhrase(memberSpawnSnapshot?.summary, activeJoiningPhrase);
|
: buildPendingDiagnosticPhrase({
|
||||||
|
summary: memberSpawnSnapshot?.summary,
|
||||||
|
memberSpawnStatuses,
|
||||||
|
memberSpawnSnapshotStatuses: memberSpawnSnapshot?.statuses,
|
||||||
|
memberSpawnSnapshotUpdatedAt: memberSpawnSnapshot?.updatedAt,
|
||||||
|
fallbackJoiningPhrase: activeJoiningPhrase,
|
||||||
|
});
|
||||||
return {
|
return {
|
||||||
progress,
|
progress,
|
||||||
isActive: true,
|
isActive: true,
|
||||||
|
|
|
||||||
|
|
@ -1043,6 +1043,8 @@ export interface TeamAgentRuntimeEntry {
|
||||||
runtimeSessionId?: string;
|
runtimeSessionId?: string;
|
||||||
runtimeLeaseExpiresAt?: string;
|
runtimeLeaseExpiresAt?: string;
|
||||||
runtimeLastSeenAt?: string;
|
runtimeLastSeenAt?: string;
|
||||||
|
/** True when a previous/persisted launch confirmed bootstrap, separate from current live liveness. */
|
||||||
|
historicalBootstrapConfirmed?: boolean;
|
||||||
runtimeDiagnostic?: string;
|
runtimeDiagnostic?: string;
|
||||||
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
runtimeDiagnosticSeverity?: TeamAgentRuntimeDiagnosticSeverity;
|
||||||
diagnostics?: string[];
|
diagnostics?: string[];
|
||||||
|
|
|
||||||
|
|
@ -24,13 +24,28 @@ export function getReviewStateFromTask(task: ReviewStateLike): TeamReviewState {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const explicit = normalizeReviewState(task.reviewState);
|
const fallbackStatus = typeof task.status === 'string' ? task.status : null;
|
||||||
if (explicit !== 'none') {
|
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;
|
return explicit;
|
||||||
}
|
};
|
||||||
|
|
||||||
|
const explicit = normalizeFallback(task.reviewState);
|
||||||
|
if (explicit) return explicit;
|
||||||
|
|
||||||
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
if (task.kanbanColumn === 'review' || task.kanbanColumn === 'approved') {
|
||||||
return task.kanbanColumn;
|
return normalizeFallback(task.kanbanColumn) ?? 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
return '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';
|
export type TaskChangeStateBucket = 'approved' | 'review' | 'completed' | 'active';
|
||||||
|
|
||||||
|
|
@ -11,25 +11,8 @@ interface TaskChangeStateLike {
|
||||||
kanbanColumn?: 'review' | 'approved' | null;
|
kanbanColumn?: 'review' | 'approved' | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizeReviewState(value: unknown): TeamReviewState {
|
|
||||||
return value === 'review' || value === 'needsFix' || value === 'approved' ? value : 'none';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
|
function getEffectiveReviewState(task: TaskChangeStateLike): TeamReviewState {
|
||||||
if (Array.isArray(task.historyEvents) && task.historyEvents.length > 0) {
|
return getReviewStateFromTask(task);
|
||||||
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';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getTaskChangeStateBucket(task: TaskChangeStateLike): TaskChangeStateBucket {
|
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', {
|
expect(logger.warn).toHaveBeenCalledWith('recent-projects source failed', {
|
||||||
sourceId: 'source-1',
|
sourceId: 'source-1',
|
||||||
sourceIndex: 1,
|
sourceIndex: 1,
|
||||||
|
|
@ -165,7 +165,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
||||||
cacheKey: 'recent-projects:fresh',
|
cacheKey: 'recent-projects:fresh',
|
||||||
count: 1,
|
count: 1,
|
||||||
degradedSources: 1,
|
degradedSources: 1,
|
||||||
cacheTtlMs: 1_500,
|
cacheTtlMs: 30_000,
|
||||||
durationMs: 250,
|
durationMs: 250,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -242,7 +242,7 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
||||||
expect(cache.set).toHaveBeenCalledWith(
|
expect(cache.set).toHaveBeenCalledWith(
|
||||||
'recent-projects:timeout',
|
'recent-projects:timeout',
|
||||||
{ ids: ['repo:fast'], sources: ['claude'] },
|
{ ids: ['repo:fast'], sources: ['claude'] },
|
||||||
1_500
|
30_000
|
||||||
);
|
);
|
||||||
} finally {
|
} finally {
|
||||||
vi.useRealTimers();
|
vi.useRealTimers();
|
||||||
|
|
@ -311,13 +311,13 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
||||||
expect(cache.set).toHaveBeenCalledWith(
|
expect(cache.set).toHaveBeenCalledWith(
|
||||||
'recent-projects:stale',
|
'recent-projects:stale',
|
||||||
{ ids: ['repo:fresh'], sources: ['claude'] },
|
{ ids: ['repo:fresh'], sources: ['claude'] },
|
||||||
1_500
|
30_000
|
||||||
);
|
);
|
||||||
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
|
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
|
||||||
cacheKey: 'recent-projects:stale',
|
cacheKey: 'recent-projects:stale',
|
||||||
count: 1,
|
count: 1,
|
||||||
degradedSources: 1,
|
degradedSources: 1,
|
||||||
cacheTtlMs: 1_500,
|
cacheTtlMs: 30_000,
|
||||||
durationMs: 200,
|
durationMs: 200,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -364,11 +364,11 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
||||||
|
|
||||||
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale);
|
await expect(useCase.execute('recent-projects:stale')).resolves.toEqual(stale);
|
||||||
expect(output.present).not.toHaveBeenCalled();
|
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', {
|
expect(logger.info).toHaveBeenCalledWith('recent-projects served stale cache', {
|
||||||
cacheKey: 'recent-projects:stale',
|
cacheKey: 'recent-projects:stale',
|
||||||
degradedSources: 1,
|
degradedSources: 1,
|
||||||
cacheTtlMs: 1_500,
|
cacheTtlMs: 30_000,
|
||||||
durationMs: 200,
|
durationMs: 200,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -431,13 +431,13 @@ describe('ListDashboardRecentProjectsUseCase', () => {
|
||||||
expect(cache.set).toHaveBeenCalledWith(
|
expect(cache.set).toHaveBeenCalledWith(
|
||||||
'recent-projects:explicit-degraded',
|
'recent-projects:explicit-degraded',
|
||||||
{ ids: ['repo:alpha'], sources: ['claude'] },
|
{ ids: ['repo:alpha'], sources: ['claude'] },
|
||||||
1_500
|
30_000
|
||||||
);
|
);
|
||||||
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
|
expect(logger.info).toHaveBeenCalledWith('recent-projects loaded', {
|
||||||
cacheKey: 'recent-projects:explicit-degraded',
|
cacheKey: 'recent-projects:explicit-degraded',
|
||||||
count: 1,
|
count: 1,
|
||||||
degradedSources: 1,
|
degradedSources: 1,
|
||||||
cacheTtlMs: 1_500,
|
cacheTtlMs: 30_000,
|
||||||
durationMs: 0,
|
durationMs: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -166,7 +166,18 @@ describe('CodexRecentProjectsSourceAdapter', () => {
|
||||||
candidates: [],
|
candidates: [],
|
||||||
degraded: true,
|
degraded: true,
|
||||||
});
|
});
|
||||||
|
await expect(adapter.list()).resolves.toEqual({
|
||||||
|
candidates: [],
|
||||||
|
degraded: true,
|
||||||
|
});
|
||||||
|
expect(appServerClient.listRecentThreads).toHaveBeenCalledTimes(1);
|
||||||
expect(appServerClient.listRecentLiveThreads).not.toHaveBeenCalled();
|
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 () => {
|
it('drops Codex appstyle temp workspaces from dashboard candidates', async () => {
|
||||||
|
|
@ -209,4 +220,81 @@ describe('CodexRecentProjectsSourceAdapter', () => {
|
||||||
|
|
||||||
expect(identityResolver.resolve).not.toHaveBeenCalled();
|
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', () => {
|
describe('CodexAppServerClient', () => {
|
||||||
it('loads live and archived threads in a single app-server session', async () => {
|
it('loads live and archived threads in a single app-server session', async () => {
|
||||||
const session = createSession(
|
const request = vi
|
||||||
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
|
.fn()
|
||||||
|
.mockImplementation((method: string, params?: { archived?: boolean }) => {
|
||||||
if (method === 'initialize') {
|
if (method === 'initialize') {
|
||||||
return Promise.resolve({});
|
return Promise.resolve({});
|
||||||
}
|
}
|
||||||
|
|
@ -40,8 +41,8 @@ describe('CodexAppServerClient', () => {
|
||||||
}
|
}
|
||||||
|
|
||||||
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));
|
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
|
||||||
const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient);
|
const client = new CodexAppServerClient({ withSession } as unknown as JsonRpcStdioClient);
|
||||||
|
|
@ -58,11 +59,23 @@ describe('CodexAppServerClient', () => {
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
binaryPath: '/usr/local/bin/codex',
|
binaryPath: '/usr/local/bin/codex',
|
||||||
requestTimeoutMs: 4500,
|
requestTimeoutMs: 4500,
|
||||||
totalTimeoutMs: 12000,
|
totalTimeoutMs: 14500,
|
||||||
}),
|
}),
|
||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
);
|
);
|
||||||
expect(session.notify).toHaveBeenCalledWith('initialized');
|
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({
|
expect(result).toEqual({
|
||||||
live: {
|
live: {
|
||||||
threads: [{ id: 'live-1', cwd: '/Users/test/live-project', source: 'cli' }],
|
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(
|
const session = createSession(
|
||||||
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
|
vi.fn().mockImplementation((method: string, params?: { archived?: boolean }) => {
|
||||||
if (method === 'initialize') {
|
if (method === 'initialize') {
|
||||||
|
|
@ -140,7 +153,7 @@ describe('CodexAppServerClient', () => {
|
||||||
|
|
||||||
expect(withSession).toHaveBeenCalledWith(
|
expect(withSession).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
totalTimeoutMs: 12000,
|
totalTimeoutMs: 14500,
|
||||||
}),
|
}),
|
||||||
expect.any(Function)
|
expect.any(Function)
|
||||||
);
|
);
|
||||||
|
|
@ -187,18 +200,20 @@ describe('CodexAppServerClient', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the longer initialize timeout for app-server startup', async () => {
|
it('uses the longer initialize timeout for app-server startup', async () => {
|
||||||
const request = vi.fn().mockImplementation((method: string, _params?: unknown, timeoutMs?: number) => {
|
const request = vi
|
||||||
if (method === 'initialize') {
|
.fn()
|
||||||
expect(timeoutMs).toBe(6000);
|
.mockImplementation((method: string, _params?: unknown, timeoutMs?: number) => {
|
||||||
return Promise.resolve({});
|
if (method === 'initialize') {
|
||||||
}
|
expect(timeoutMs).toBe(6000);
|
||||||
|
return Promise.resolve({});
|
||||||
|
}
|
||||||
|
|
||||||
if (method === 'thread/list') {
|
if (method === 'thread/list') {
|
||||||
return Promise.resolve({ data: [] });
|
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 session = createSession(request);
|
||||||
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
|
const withSession = vi.fn().mockImplementation((_options, handler) => handler(session));
|
||||||
|
|
|
||||||
|
|
@ -103,7 +103,7 @@ describe('recentProjectsClientCache', () => {
|
||||||
await expect(second).resolves.toEqual(payload('alpha'));
|
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.useFakeTimers();
|
||||||
vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z'));
|
vi.setSystemTime(new Date('2026-04-14T12:00:00.000Z'));
|
||||||
|
|
||||||
|
|
@ -121,7 +121,13 @@ describe('recentProjectsClientCache', () => {
|
||||||
isStale: false,
|
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({
|
expect(getRecentProjectsClientSnapshot()).toMatchObject({
|
||||||
payload: payload('alpha', { degraded: true }),
|
payload: payload('alpha', { degraded: true }),
|
||||||
isStale: true,
|
isStale: true,
|
||||||
|
|
@ -129,7 +135,9 @@ describe('recentProjectsClientCache', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('normalizes legacy array responses from the loader during mixed-version dev reloads', async () => {
|
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'));
|
await expect(loadRecentProjectsWithClientCache(loader)).resolves.toEqual(payload('alpha'));
|
||||||
expect(getRecentProjectsClientSnapshot()?.payload).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: {
|
alice: {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -501,8 +501,9 @@ describe('OpenCodeTeamRuntimeAdapter', () => {
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
pendingPermissionRequestIds: ['perm-1', 'perm-2'],
|
pendingPermissionRequestIds: ['perm-1', 'perm-2'],
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
|
livenessKind: 'permission_blocked',
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: 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 () => {
|
it('keeps missing bridge members in bootstrap pending even when another member blocks on permission', async () => {
|
||||||
const launchOpenCodeTeam = vi.fn(
|
const launchOpenCodeTeam = vi.fn(
|
||||||
async () =>
|
async () =>
|
||||||
|
|
|
||||||
|
|
@ -211,9 +211,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
const statuses = await svc.getMemberSpawnStatuses('permission-opencode-safe-e2e');
|
const statuses = await svc.getMemberSpawnStatuses('permission-opencode-safe-e2e');
|
||||||
expect(statuses.teamLaunchState).toBe('partial_pending');
|
expect(statuses.teamLaunchState).toBe('partial_pending');
|
||||||
expect(statuses.statuses.alice).toMatchObject({
|
expect(statuses.statuses.alice).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
pendingPermissionRequestIds: ['perm-alice'],
|
pendingPermissionRequestIds: ['perm-alice'],
|
||||||
});
|
});
|
||||||
expect(statuses.summary?.pendingCount).toBe(1);
|
expect(statuses.summary?.pendingCount).toBe(1);
|
||||||
|
|
@ -255,9 +255,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.bob).toMatchObject({
|
expect(statuses.statuses.bob).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
pendingPermissionRequestIds: ['perm-bob'],
|
pendingPermissionRequestIds: ['perm-bob'],
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
|
|
@ -2300,7 +2300,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
confirmedCount: 2,
|
confirmedCount: 2,
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.alice).toMatchObject({
|
expect(statuses.statuses.alice).toMatchObject({
|
||||||
status: 'online',
|
status: 'online',
|
||||||
|
|
@ -2313,9 +2313,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
|
|
@ -2909,7 +2909,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
confirmedCount: 1,
|
confirmedCount: 1,
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.alice).toMatchObject({
|
expect(statuses.statuses.alice).toMatchObject({
|
||||||
status: 'online',
|
status: 'online',
|
||||||
|
|
@ -2917,9 +2917,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.bob).toMatchObject({
|
expect(statuses.statuses.bob).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
|
|
@ -2972,12 +2972,12 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
confirmedCount: 1,
|
confirmedCount: 1,
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.bob).toMatchObject({
|
expect(statuses.statuses.bob).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
pendingPermissionRequestIds: ['perm-bob'],
|
pendingPermissionRequestIds: ['perm-bob'],
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
|
|
@ -3301,13 +3301,13 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
confirmedCount: 1,
|
confirmedCount: 1,
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.bob).toMatchObject({
|
expect(statuses.statuses.bob).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
|
|
@ -3361,13 +3361,13 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
confirmedCount: 1,
|
confirmedCount: 1,
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.bob).toMatchObject({
|
expect(statuses.statuses.bob).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
agentToolAccepted: true,
|
agentToolAccepted: true,
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
pendingPermissionRequestIds: ['perm-bob'],
|
pendingPermissionRequestIds: ['perm-bob'],
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
|
|
@ -3469,7 +3469,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
pendingPermissionRequestIds: ['perm-tom'],
|
pendingPermissionRequestIds: ['perm-tom'],
|
||||||
|
|
@ -3588,7 +3588,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
pendingPermissionRequestIds: ['perm-tom'],
|
pendingPermissionRequestIds: ['perm-tom'],
|
||||||
|
|
@ -4129,6 +4129,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
{
|
{
|
||||||
alive: true,
|
alive: true,
|
||||||
model: 'opencode/minimax-m2.5-free',
|
model: 'opencode/minimax-m2.5-free',
|
||||||
|
livenessKind: 'runtime_process',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
@ -4196,6 +4197,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
{
|
{
|
||||||
alive: true,
|
alive: true,
|
||||||
model: 'opencode/minimax-m2.5-free',
|
model: 'opencode/minimax-m2.5-free',
|
||||||
|
livenessKind: 'runtime_process',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
]);
|
]);
|
||||||
|
|
@ -4364,9 +4366,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
bootstrapConfirmed: true,
|
bootstrapConfirmed: true,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
pendingPermissionRequestIds: ['perm-tom'],
|
pendingPermissionRequestIds: ['perm-tom'],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -4537,12 +4539,12 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
confirmedCount: 2,
|
confirmedCount: 2,
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.alice).toMatchObject({
|
expect(statuses.statuses.alice).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
pendingPermissionRequestIds: ['perm-alice'],
|
pendingPermissionRequestIds: ['perm-alice'],
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
|
|
@ -4591,7 +4593,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
confirmedCount: 2,
|
confirmedCount: 2,
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.alice).toMatchObject({
|
expect(statuses.statuses.alice).toMatchObject({
|
||||||
status: 'online',
|
status: 'online',
|
||||||
|
|
@ -4606,9 +4608,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
|
|
@ -4629,7 +4631,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
expect(runtimeSnapshot.members.tom).toMatchObject({
|
expect(runtimeSnapshot.members.tom).toMatchObject({
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
laneKind: 'secondary',
|
laneKind: 'secondary',
|
||||||
alive: true,
|
alive: false,
|
||||||
runtimeModel: 'opencode/nemotron-3-super-free',
|
runtimeModel: 'opencode/nemotron-3-super-free',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -4694,7 +4696,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
confirmedCount: 2,
|
confirmedCount: 2,
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 1,
|
failedCount: 1,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.alice).toMatchObject({
|
expect(statuses.statuses.alice).toMatchObject({
|
||||||
status: 'online',
|
status: 'online',
|
||||||
|
|
@ -4715,9 +4717,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
|
|
@ -4744,7 +4746,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
expect(runtimeSnapshot.members.tom).toMatchObject({
|
expect(runtimeSnapshot.members.tom).toMatchObject({
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
laneKind: 'secondary',
|
laneKind: 'secondary',
|
||||||
alive: true,
|
alive: false,
|
||||||
runtimeModel: 'opencode/nemotron-3-super-free',
|
runtimeModel: 'opencode/nemotron-3-super-free',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -4934,7 +4936,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
pendingPermissionRequestIds: ['perm-tom'],
|
pendingPermissionRequestIds: ['perm-tom'],
|
||||||
|
|
@ -5392,8 +5394,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||||
new Map([
|
new Map([
|
||||||
['alice', { alive: true, model: 'haiku' }],
|
['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }],
|
||||||
['bob-2', { alive: true, model: 'sonnet' }],
|
['bob-2', { alive: true, model: 'sonnet', livenessKind: 'runtime_process' }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
@ -5458,8 +5460,15 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||||
new Map([
|
new Map([
|
||||||
['alice', { alive: true, model: 'haiku' }],
|
['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }],
|
||||||
['bob-2', { alive: true, model: 'opencode/minimax-m2.5-free' }],
|
[
|
||||||
|
'bob-2',
|
||||||
|
{
|
||||||
|
alive: true,
|
||||||
|
model: 'opencode/minimax-m2.5-free',
|
||||||
|
livenessKind: 'runtime_process',
|
||||||
|
},
|
||||||
|
],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
@ -6832,8 +6841,8 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
||||||
new Map([
|
new Map([
|
||||||
['alice', { alive: true, model: 'haiku' }],
|
['alice', { alive: true, model: 'haiku', livenessKind: 'confirmed_bootstrap' }],
|
||||||
['bob-2', { alive: true, model: 'sonnet' }],
|
['bob-2', { alive: true, model: 'sonnet', livenessKind: 'runtime_process' }],
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
@ -6966,7 +6975,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
});
|
});
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).getLiveTeamAgentRuntimeMetadata = async () =>
|
(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);
|
const statuses = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
|
@ -13102,7 +13111,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailureReason: 'Gemini pane failed to start',
|
hardFailureReason: 'Gemini pane failed to start',
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.bob).toMatchObject({
|
expect(statuses.statuses.bob).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
pendingPermissionRequestIds: ['perm-bob'],
|
pendingPermissionRequestIds: ['perm-bob'],
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
|
|
@ -13238,7 +13247,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
launchState: 'confirmed_alive',
|
launchState: 'confirmed_alive',
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.bob).toMatchObject({
|
expect(statuses.statuses.bob).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
pendingPermissionRequestIds: ['perm-bob'],
|
pendingPermissionRequestIds: ['perm-bob'],
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
|
|
@ -13291,7 +13300,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.bob).toMatchObject({
|
expect(statuses.statuses.bob).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
pendingPermissionRequestIds: ['perm-bob'],
|
pendingPermissionRequestIds: ['perm-bob'],
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
|
|
@ -13375,7 +13384,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
});
|
});
|
||||||
expect(secondStatuses.teamLaunchState).toBe('partial_pending');
|
expect(secondStatuses.teamLaunchState).toBe('partial_pending');
|
||||||
expect(secondStatuses.statuses.bob).toMatchObject({
|
expect(secondStatuses.statuses.bob).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
pendingPermissionRequestIds: ['perm-bob'],
|
pendingPermissionRequestIds: ['perm-bob'],
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
|
|
@ -15570,7 +15579,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
pendingPermissionRequestIds: ['perm-tom'],
|
pendingPermissionRequestIds: ['perm-tom'],
|
||||||
|
|
@ -15622,7 +15631,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
confirmedCount: 1,
|
confirmedCount: 1,
|
||||||
pendingCount: 2,
|
pendingCount: 2,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.bob).toMatchObject({
|
expect(statuses.statuses.bob).toMatchObject({
|
||||||
status: 'online',
|
status: 'online',
|
||||||
|
|
@ -15630,9 +15639,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
|
|
@ -15703,7 +15712,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
pendingPermissionRequestIds: ['perm-tom'],
|
pendingPermissionRequestIds: ['perm-tom'],
|
||||||
|
|
@ -15763,7 +15772,7 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
confirmedCount: 1,
|
confirmedCount: 1,
|
||||||
pendingCount: 3,
|
pendingCount: 3,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
});
|
});
|
||||||
expect(statuses.expectedMembers).toEqual(
|
expect(statuses.expectedMembers).toEqual(
|
||||||
expect.arrayContaining(['alice', 'reviewer', 'bob', 'tom'])
|
expect.arrayContaining(['alice', 'reviewer', 'bob', 'tom'])
|
||||||
|
|
@ -15776,9 +15785,9 @@ describe('Team agent launch matrix safe e2e', () => {
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
expect(statuses.statuses.tom).toMatchObject({
|
expect(statuses.statuses.tom).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
|
|
@ -16058,6 +16067,18 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
const failed = outcome === 'failed';
|
const failed = outcome === 'failed';
|
||||||
const permissionPending = outcome === 'permission';
|
const permissionPending = outcome === 'permission';
|
||||||
const bootstrapPending = outcome === 'launching';
|
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 {
|
return {
|
||||||
memberName: member.name,
|
memberName: member.name,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
|
|
@ -16069,12 +16090,15 @@ class FakeOpenCodeRuntimeAdapter implements TeamLaunchRuntimeAdapter {
|
||||||
? 'runtime_pending_bootstrap'
|
? 'runtime_pending_bootstrap'
|
||||||
: 'confirmed_alive',
|
: 'confirmed_alive',
|
||||||
agentToolAccepted: !failed,
|
agentToolAccepted: !failed,
|
||||||
runtimeAlive: !failed,
|
runtimeAlive: !failed && !permissionPending && !bootstrapPending,
|
||||||
bootstrapConfirmed: !failed && !permissionPending && !bootstrapPending,
|
bootstrapConfirmed: !failed && !permissionPending && !bootstrapPending,
|
||||||
hardFailure: failed,
|
hardFailure: failed,
|
||||||
hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined,
|
hardFailureReason: failed ? 'fake_open_code_launch_failure' : undefined,
|
||||||
pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined,
|
pendingPermissionRequestIds: permissionPending ? [`perm-${member.name}`] : undefined,
|
||||||
runtimePid: failed ? undefined : 10_000 + index,
|
runtimePid: failed ? undefined : 10_000 + index,
|
||||||
|
livenessKind,
|
||||||
|
pidSource: failed ? undefined : 'opencode_bridge',
|
||||||
|
runtimeDiagnostic,
|
||||||
diagnostics: failed
|
diagnostics: failed
|
||||||
? ['fake OpenCode launch failure']
|
? ['fake OpenCode launch failure']
|
||||||
: permissionPending
|
: permissionPending
|
||||||
|
|
|
||||||
|
|
@ -279,6 +279,39 @@ describe('TeamBootstrapStateReader', () => {
|
||||||
await expect(readBootstrapRuntimeState('demo')).resolves.toBeNull();
|
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 () => {
|
it('reads persisted real-task submission state', async () => {
|
||||||
hoisted.files.set('/mock/teams/demo/bootstrap-state.json', {
|
hoisted.files.set('/mock/teams/demo/bootstrap-state.json', {
|
||||||
contents: JSON.stringify({
|
contents: JSON.stringify({
|
||||||
|
|
|
||||||
|
|
@ -242,10 +242,12 @@ function createForwardingJournalStore(initialEntries: Array<Record<string, unkno
|
||||||
const journal = {
|
const journal = {
|
||||||
exists: vi.fn(async () => true),
|
exists: vi.fn(async () => true),
|
||||||
ensureFile: vi.fn(async () => undefined),
|
ensureFile: vi.fn(async () => undefined),
|
||||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
withEntries: vi.fn(
|
||||||
const outcome = await fn(journalEntries);
|
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||||
return outcome.result;
|
const outcome = await fn(journalEntries);
|
||||||
}),
|
return outcome.result;
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
return { journalEntries, journal };
|
return { journalEntries, journal };
|
||||||
|
|
@ -262,7 +264,9 @@ function createTaskCommentForwardingService(options: {
|
||||||
};
|
};
|
||||||
members?: Array<{ name: string; role?: string }>;
|
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 journal = options.journal ?? createForwardingJournalStore().journal;
|
||||||
|
|
||||||
const service = new TeamDataService(
|
const service = new TeamDataService(
|
||||||
|
|
@ -328,24 +332,26 @@ function buildDefaultTeamConfig(overrides: Partial<TeamConfig> = {}): TeamConfig
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
function createGetTeamDataHarness(options: {
|
function createGetTeamDataHarness(
|
||||||
config?: TeamConfig | null;
|
options: {
|
||||||
getTasks?: () => Promise<TeamTask[]>;
|
config?: TeamConfig | null;
|
||||||
listInboxNames?: () => Promise<string[]>;
|
getTasks?: () => Promise<TeamTask[]>;
|
||||||
getMessages?: () => Promise<InboxMessage[]>;
|
listInboxNames?: () => Promise<string[]>;
|
||||||
getMembers?: () => Promise<TeamConfig['members']>;
|
getMessages?: () => Promise<InboxMessage[]>;
|
||||||
getTeamMeta?: () => Promise<TeamMetaFile | null>;
|
getMembers?: () => Promise<TeamConfig['members']>;
|
||||||
getState?: () => Promise<KanbanState>;
|
getTeamMeta?: () => Promise<TeamMetaFile | null>;
|
||||||
readMessages?: () => Promise<InboxMessage[]>;
|
getState?: () => Promise<KanbanState>;
|
||||||
resolveMembers?: (
|
readMessages?: () => Promise<InboxMessage[]>;
|
||||||
config: TeamConfig,
|
resolveMembers?: (
|
||||||
metaMembers: TeamConfig['members'],
|
config: TeamConfig,
|
||||||
inboxNames: string[],
|
metaMembers: TeamConfig['members'],
|
||||||
tasks: TeamTaskWithKanban[]
|
inboxNames: string[],
|
||||||
) => ResolvedTeamMember[];
|
tasks: TeamTaskWithKanban[]
|
||||||
listProcesses?: () => TeamProcess[];
|
) => ResolvedTeamMember[];
|
||||||
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
|
listProcesses?: () => TeamProcess[];
|
||||||
} = {}) {
|
getMemberAdvisories?: () => Promise<Map<string, unknown>>;
|
||||||
|
} = {}
|
||||||
|
) {
|
||||||
const getConfig = vi.fn(async () =>
|
const getConfig = vi.fn(async () =>
|
||||||
options.config === undefined ? buildDefaultTeamConfig() : options.config
|
options.config === undefined ? buildDefaultTeamConfig() : options.config
|
||||||
);
|
);
|
||||||
|
|
@ -486,7 +492,9 @@ describe('TeamDataService', () => {
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ resolveMembers: vi.fn(() => []) } 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,
|
{} as never,
|
||||||
membersMetaStore,
|
membersMetaStore,
|
||||||
{ readMessages: vi.fn(async () => []) } as never
|
{ readMessages: vi.fn(async () => []) } as never
|
||||||
|
|
@ -518,7 +526,9 @@ describe('TeamDataService', () => {
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ resolveMembers: vi.fn(() => []) } 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,
|
{} as never,
|
||||||
membersMetaStore,
|
membersMetaStore,
|
||||||
{ readMessages: vi.fn(async () => []) } as never
|
{ readMessages: vi.fn(async () => []) } as never
|
||||||
|
|
@ -1006,7 +1016,10 @@ describe('TeamDataService', () => {
|
||||||
const service = new TeamDataService(
|
const service = new TeamDataService(
|
||||||
{
|
{
|
||||||
listTeams: vi.fn(),
|
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,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
|
|
@ -1134,7 +1147,10 @@ describe('TeamDataService', () => {
|
||||||
const service = new TeamDataService(
|
const service = new TeamDataService(
|
||||||
{
|
{
|
||||||
listTeams: vi.fn(),
|
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,
|
||||||
{
|
{
|
||||||
getTasks: vi.fn(async () => []),
|
getTasks: vi.fn(async () => []),
|
||||||
|
|
@ -1305,7 +1321,9 @@ describe('TeamDataService', () => {
|
||||||
expect(createTaskMock).toHaveBeenCalledWith(
|
expect(createTaskMock).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ owner: 'alice', createdBy: 'user' })
|
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 () => {
|
it('creates task with explicit immediate start only when startImmediately is true', async () => {
|
||||||
|
|
@ -1362,7 +1380,9 @@ describe('TeamDataService', () => {
|
||||||
prompt: 'Begin immediately.',
|
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 () => {
|
it('persists explicit related task links when creating a task', async () => {
|
||||||
|
|
@ -1486,7 +1506,47 @@ describe('TeamDataService', () => {
|
||||||
await service.requestReview('my-team', 'task-1');
|
await service.requestReview('my-team', 'task-1');
|
||||||
|
|
||||||
expect(requestReviewMock).toHaveBeenCalledWith('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',
|
leadSessionId: 'lead-1',
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -1511,8 +1571,15 @@ describe('TeamDataService', () => {
|
||||||
subject: 'Legacy review task',
|
subject: 'Legacy review task',
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
owner: 'bob',
|
owner: 'bob',
|
||||||
reviewState: 'review',
|
reviewState: 'none',
|
||||||
historyEvents: [],
|
historyEvents: [
|
||||||
|
{
|
||||||
|
id: 'evt-created',
|
||||||
|
type: 'task_created',
|
||||||
|
status: 'completed',
|
||||||
|
timestamp: '2026-03-01T09:00:00.000Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
]),
|
]),
|
||||||
} as never,
|
} 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 () => {
|
it('propagates leadSessionId for kanban-driven review transitions', async () => {
|
||||||
const requestReviewMock = vi.fn();
|
const requestReviewMock = vi.fn();
|
||||||
const approveReviewMock = 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: 'review' });
|
||||||
await service.updateKanban('my-team', 'task-1', { op: 'set_column', column: 'approved' });
|
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', {
|
expect(requestReviewMock).toHaveBeenCalledWith('task-1', {
|
||||||
from: 'user',
|
from: 'lead',
|
||||||
leadSessionId: 'lead-2',
|
leadSessionId: 'lead-2',
|
||||||
});
|
});
|
||||||
expect(approveReviewMock).toHaveBeenCalledWith('task-1', {
|
expect(approveReviewMock).toHaveBeenCalledWith('task-1', {
|
||||||
from: 'user',
|
from: 'lead',
|
||||||
suppressTaskComment: true,
|
suppressTaskComment: true,
|
||||||
'notify-owner': true,
|
'notify-owner': true,
|
||||||
leadSessionId: 'lead-2',
|
leadSessionId: 'lead-2',
|
||||||
});
|
});
|
||||||
expect(requestChangesMock).toHaveBeenCalledWith('task-1', {
|
expect(requestChangesMock).toHaveBeenCalledWith('task-1', {
|
||||||
from: 'user',
|
from: 'lead',
|
||||||
comment: 'Needs fixes',
|
comment: 'Needs fixes',
|
||||||
leadSessionId: 'lead-2',
|
leadSessionId: 'lead-2',
|
||||||
});
|
});
|
||||||
|
|
@ -1615,10 +1808,12 @@ describe('TeamDataService', () => {
|
||||||
ensureFile: vi.fn(async () => {
|
ensureFile: vi.fn(async () => {
|
||||||
journalExists = true;
|
journalExists = true;
|
||||||
}),
|
}),
|
||||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
withEntries: vi.fn(
|
||||||
const outcome = await fn(journalEntries);
|
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||||
return outcome.result;
|
const outcome = await fn(journalEntries);
|
||||||
}),
|
return outcome.result;
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1707,10 +1902,12 @@ describe('TeamDataService', () => {
|
||||||
const journal = {
|
const journal = {
|
||||||
exists: vi.fn(async () => true),
|
exists: vi.fn(async () => true),
|
||||||
ensureFile: vi.fn(async () => undefined),
|
ensureFile: vi.fn(async () => undefined),
|
||||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
withEntries: vi.fn(
|
||||||
const outcome = await fn(journalEntries);
|
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||||
return outcome.result;
|
const outcome = await fn(journalEntries);
|
||||||
}),
|
return outcome.result;
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1776,10 +1973,9 @@ describe('TeamDataService', () => {
|
||||||
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
messageId: 'task-comment-forward:my-team:task-1:comment-1',
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
const firstSendRequest = (inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } })
|
const firstSendRequest = (
|
||||||
.mock.calls[0]?.[1] as
|
inboxWriter.sendMessage as unknown as { mock: { calls: unknown[][] } }
|
||||||
| { text?: string }
|
).mock.calls[0]?.[1] as { text?: string } | undefined;
|
||||||
| undefined;
|
|
||||||
expect(String(firstSendRequest?.text ?? '')).not.toContain('<agent-block>');
|
expect(String(firstSendRequest?.text ?? '')).not.toContain('<agent-block>');
|
||||||
const sentEntry = journalEntries.find((entry) => entry.key === 'task-1:comment-1');
|
const sentEntry = journalEntries.find((entry) => entry.key === 'task-1:comment-1');
|
||||||
expect(sentEntry).toMatchObject({
|
expect(sentEntry).toMatchObject({
|
||||||
|
|
@ -1803,10 +1999,12 @@ describe('TeamDataService', () => {
|
||||||
ensureFile: vi.fn(async () => {
|
ensureFile: vi.fn(async () => {
|
||||||
journalExists = true;
|
journalExists = true;
|
||||||
}),
|
}),
|
||||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
withEntries: vi.fn(
|
||||||
const outcome = await fn(journalEntries);
|
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||||
return outcome.result;
|
const outcome = await fn(journalEntries);
|
||||||
}),
|
return outcome.result;
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1902,10 +2100,12 @@ describe('TeamDataService', () => {
|
||||||
const journal = {
|
const journal = {
|
||||||
exists: vi.fn(async () => true),
|
exists: vi.fn(async () => true),
|
||||||
ensureFile: vi.fn(async () => undefined),
|
ensureFile: vi.fn(async () => undefined),
|
||||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
withEntries: vi.fn(
|
||||||
const outcome = await fn(journalEntries);
|
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||||
return outcome.result;
|
const outcome = await fn(journalEntries);
|
||||||
}),
|
return outcome.result;
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -1983,10 +2183,12 @@ describe('TeamDataService', () => {
|
||||||
const journal = {
|
const journal = {
|
||||||
exists: vi.fn(async () => true),
|
exists: vi.fn(async () => true),
|
||||||
ensureFile: vi.fn(async () => undefined),
|
ensureFile: vi.fn(async () => undefined),
|
||||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
withEntries: vi.fn(
|
||||||
const outcome = await fn(journalEntries);
|
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||||
return outcome.result;
|
const outcome = await fn(journalEntries);
|
||||||
}),
|
return outcome.result;
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -2081,15 +2283,20 @@ describe('TeamDataService', () => {
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
const inboxWriter = {
|
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 = {
|
const journal = {
|
||||||
exists: vi.fn(async () => true),
|
exists: vi.fn(async () => true),
|
||||||
ensureFile: vi.fn(async () => undefined),
|
ensureFile: vi.fn(async () => undefined),
|
||||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
withEntries: vi.fn(
|
||||||
const outcome = await fn(journalEntries);
|
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||||
return outcome.result;
|
const outcome = await fn(journalEntries);
|
||||||
}),
|
return outcome.result;
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -2183,10 +2390,12 @@ describe('TeamDataService', () => {
|
||||||
const journal = {
|
const journal = {
|
||||||
exists: vi.fn(async () => true),
|
exists: vi.fn(async () => true),
|
||||||
ensureFile: vi.fn(async () => undefined),
|
ensureFile: vi.fn(async () => undefined),
|
||||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
withEntries: vi.fn(
|
||||||
const outcome = await fn(journalEntries);
|
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||||
return outcome.result;
|
const outcome = await fn(journalEntries);
|
||||||
}),
|
return outcome.result;
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -2283,10 +2492,12 @@ describe('TeamDataService', () => {
|
||||||
const journal = {
|
const journal = {
|
||||||
exists: vi.fn(async () => true),
|
exists: vi.fn(async () => true),
|
||||||
ensureFile: vi.fn(async () => undefined),
|
ensureFile: vi.fn(async () => undefined),
|
||||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
withEntries: vi.fn(
|
||||||
const outcome = await fn(journalEntries);
|
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||||
return outcome.result;
|
const outcome = await fn(journalEntries);
|
||||||
}),
|
return outcome.result;
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -2368,10 +2579,12 @@ describe('TeamDataService', () => {
|
||||||
const journal = {
|
const journal = {
|
||||||
exists: vi.fn(async () => true),
|
exists: vi.fn(async () => true),
|
||||||
ensureFile: vi.fn(async () => undefined),
|
ensureFile: vi.fn(async () => undefined),
|
||||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
withEntries: vi.fn(
|
||||||
const outcome = await fn(journalEntries);
|
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||||
return outcome.result;
|
const outcome = await fn(journalEntries);
|
||||||
}),
|
return outcome.result;
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -2611,15 +2824,19 @@ describe('TeamDataService', () => {
|
||||||
const initGate = new Promise<void>((resolve) => {
|
const initGate = new Promise<void>((resolve) => {
|
||||||
releaseInit = () => 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 journalEntries: Array<Record<string, unknown>> = [];
|
||||||
const journal = {
|
const journal = {
|
||||||
exists: vi.fn(async () => true),
|
exists: vi.fn(async () => true),
|
||||||
ensureFile: vi.fn(async () => undefined),
|
ensureFile: vi.fn(async () => undefined),
|
||||||
withEntries: vi.fn(async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
withEntries: vi.fn(
|
||||||
const outcome = await fn(journalEntries);
|
async (_teamName: string, fn: (entries: unknown[]) => Promise<{ result: unknown }>) => {
|
||||||
return outcome.result;
|
const outcome = await fn(journalEntries);
|
||||||
}),
|
return outcome.result;
|
||||||
|
}
|
||||||
|
),
|
||||||
};
|
};
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
|
@ -3420,7 +3637,9 @@ describe('TeamDataService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const feed = await service.getMessageFeed('my-team');
|
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();
|
expect(linked?.relayOfMessageId).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
@ -3988,10 +4207,7 @@ describe('TeamDataService', () => {
|
||||||
),
|
),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const firstSpy = vi.spyOn(
|
const firstSpy = vi.spyOn(firstService as never, 'extractLeadAssistantTextsFromJsonl' as never);
|
||||||
firstService as never,
|
|
||||||
'extractLeadAssistantTextsFromJsonl' as never
|
|
||||||
);
|
|
||||||
const secondSpy = vi.spyOn(
|
const secondSpy = vi.spyOn(
|
||||||
secondService as never,
|
secondService as never,
|
||||||
'extractLeadAssistantTextsFromJsonl' as never
|
'extractLeadAssistantTextsFromJsonl' as never
|
||||||
|
|
@ -4106,7 +4322,9 @@ describe('TeamDataService', () => {
|
||||||
const service = createResolverBackedService();
|
const service = createResolverBackedService();
|
||||||
|
|
||||||
const page = await service.getMessagesPage(fixture.teamName, { limit: 20 });
|
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(
|
expect(
|
||||||
leadSessionMessages.some((message) =>
|
leadSessionMessages.some((message) =>
|
||||||
|
|
@ -4187,12 +4405,7 @@ describe('TeamDataService', () => {
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
||||||
expect(order).toEqual(
|
expect(order).toEqual(
|
||||||
expect.arrayContaining([
|
expect.arrayContaining(['inboxNames:start', 'meta:start', 'kanban:start', 'tasks:start'])
|
||||||
'inboxNames:start',
|
|
||||||
'meta:start',
|
|
||||||
'kanban:start',
|
|
||||||
'tasks:start',
|
|
||||||
])
|
|
||||||
);
|
);
|
||||||
expect(order).not.toContain('processes:start');
|
expect(order).not.toContain('processes:start');
|
||||||
expect(order).not.toContain('leadTexts:start');
|
expect(order).not.toContain('leadTexts:start');
|
||||||
|
|
@ -4453,7 +4666,11 @@ describe('TeamDataService', () => {
|
||||||
|
|
||||||
const feed = await harness.service.getMessageFeed('my-team');
|
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 () => {
|
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(() => {
|
vi.spyOn(harness.service as never, 'extractLeadSessionTexts' as never).mockImplementation(
|
||||||
order.push('leadTexts:start');
|
() => {
|
||||||
throw new Error('lead sync fail');
|
order.push('leadTexts:start');
|
||||||
});
|
throw new Error('lead sync fail');
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
const pending = harness.service.getTeamData('my-team');
|
const pending = harness.service.getTeamData('my-team');
|
||||||
await flushMicrotasks();
|
await flushMicrotasks();
|
||||||
|
|
@ -4665,7 +4884,16 @@ describe('TeamDataService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('getMessagesPage', () => {
|
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(
|
return new TeamDataService(
|
||||||
{
|
{
|
||||||
listTeams: vi.fn(),
|
listTeams: vi.fn(),
|
||||||
|
|
@ -4678,17 +4906,17 @@ describe('TeamDataService', () => {
|
||||||
{ getTasks: vi.fn(async () => []) } as never,
|
{ getTasks: vi.fn(async () => []) } as never,
|
||||||
{
|
{
|
||||||
listInboxNames: vi.fn(async () => []),
|
listInboxNames: vi.fn(async () => []),
|
||||||
getMessages: vi.fn(async () =>
|
getMessages: vi.fn(async () => messages.map((m) => ({ ...m, read: true }))),
|
||||||
messages.map((m) => ({ ...m, read: true }))
|
|
||||||
),
|
|
||||||
} as never,
|
} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{} as never,
|
{} as never,
|
||||||
{ resolveMembers: vi.fn(() => []) } 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,
|
||||||
{} 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.messages[0]?.messageId).toMatch(/^inbox-/);
|
||||||
expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!);
|
expect(page1.nextCursor).toContain(page1.messages[0]!.messageId!);
|
||||||
expect(page2.messages.every((message) => Boolean(message.messageId))).toBe(true);
|
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 () => {
|
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!,
|
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 { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
normalizePersistedLaunchSnapshot,
|
||||||
snapshotToMemberSpawnStatuses,
|
snapshotToMemberSpawnStatuses,
|
||||||
summarizePersistedLaunchMembers,
|
summarizePersistedLaunchMembers,
|
||||||
} from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
} from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
||||||
|
|
@ -54,12 +55,13 @@ describe('TeamLaunchStateEvaluator', () => {
|
||||||
});
|
});
|
||||||
expect(statuses.bob).toMatchObject({
|
expect(statuses.bob).toMatchObject({
|
||||||
launchState: 'runtime_pending_permission',
|
launchState: 'runtime_pending_permission',
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
|
runtimeAlive: false,
|
||||||
pendingPermissionRequestIds: ['req-1'],
|
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'], {
|
const summary = summarizePersistedLaunchMembers(['alice'], {
|
||||||
alice: {
|
alice: {
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
|
|
@ -75,7 +77,7 @@ describe('TeamLaunchStateEvaluator', () => {
|
||||||
confirmedCount: 0,
|
confirmedCount: 0,
|
||||||
pendingCount: 2,
|
pendingCount: 2,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 0,
|
||||||
shellOnlyPendingCount: 0,
|
shellOnlyPendingCount: 0,
|
||||||
runtimeProcessPendingCount: 0,
|
runtimeProcessPendingCount: 0,
|
||||||
runtimeCandidatePendingCount: 0,
|
runtimeCandidatePendingCount: 0,
|
||||||
|
|
@ -83,4 +85,78 @@ describe('TeamLaunchStateEvaluator', () => {
|
||||||
permissionPendingCount: 1,
|
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 () => {
|
it('prefers the newest matching agent pid when multiple processes match the same teammate', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
|
|
@ -836,8 +901,8 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(snapshot.members.alice).toBeUndefined();
|
expect(snapshot.members.alice).toBeUndefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps pure OpenCode launch members alive from confirmed launch snapshot while runtime adapter is tracked', async () => {
|
it('keeps historical bootstrap separate from current runtime liveness', async () => {
|
||||||
const teamName = 'pure-opencode-runtime-team';
|
const teamName = 'pure-opencode-runtime-team-strict';
|
||||||
const projectPath = '/Users/test/project';
|
const projectPath = '/Users/test/project';
|
||||||
writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']);
|
writeLaunchConfig(teamName, projectPath, 'lead-session', ['alice']);
|
||||||
writeLaunchState(teamName, 'lead-session', {
|
writeLaunchState(teamName, 'lead-session', {
|
||||||
|
|
@ -864,12 +929,102 @@ describe('TeamProvisioningService', () => {
|
||||||
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
const snapshot = await svc.getTeamAgentRuntimeSnapshot(teamName);
|
||||||
|
|
||||||
expect(snapshot.members.alice).toMatchObject({
|
expect(snapshot.members.alice).toMatchObject({
|
||||||
alive: true,
|
alive: false,
|
||||||
|
historicalBootstrapConfirmed: true,
|
||||||
providerId: 'opencode',
|
providerId: 'opencode',
|
||||||
runtimeModel: 'opencode/big-pickle',
|
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 () => {
|
it('excludes removed meta members from live runtime metadata resolution', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
|
|
@ -1140,6 +1295,9 @@ describe('TeamProvisioningService', () => {
|
||||||
];
|
];
|
||||||
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
|
(svc as any).aliveRunByTeam.set('runtime-team', 'run-1');
|
||||||
(svc as any).runs.set('run-1', run);
|
(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).mockReset();
|
||||||
vi.mocked(pidusage).mockImplementation(
|
vi.mocked(pidusage).mockImplementation(
|
||||||
async (target: number | string | Array<number | string>) => {
|
async (target: number | string | Array<number | string>) => {
|
||||||
|
|
@ -1216,6 +1374,9 @@ describe('TeamProvisioningService', () => {
|
||||||
),
|
),
|
||||||
};
|
};
|
||||||
vi.mocked(pidusage).mockReset();
|
vi.mocked(pidusage).mockReset();
|
||||||
|
vi.mocked(listRuntimeProcessesForCurrentTmuxPlatform).mockResolvedValue([
|
||||||
|
{ pid: 333, ppid: 1, command: 'opencode runtime host' },
|
||||||
|
]);
|
||||||
vi.mocked(pidusage).mockImplementation(
|
vi.mocked(pidusage).mockImplementation(
|
||||||
async (target: number | string | Array<number | string>) => {
|
async (target: number | string | Array<number | string>) => {
|
||||||
if (Array.isArray(target)) {
|
if (Array.isArray(target)) {
|
||||||
|
|
@ -2238,6 +2399,7 @@ describe('TeamProvisioningService', () => {
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 1,
|
||||||
|
runtimeProcessPendingCount: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
version: 2,
|
version: 2,
|
||||||
|
|
@ -2314,6 +2476,37 @@ describe('TeamProvisioningService', () => {
|
||||||
).toBe('Finishing launch — 1 teammate awaiting permission approval');
|
).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', () => {
|
it('trusts persisted snapshot permission state for pure teams when live run statuses are absent', () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const run = createMemberSpawnRun({
|
const run = createMemberSpawnRun({
|
||||||
|
|
@ -2330,6 +2523,7 @@ describe('TeamProvisioningService', () => {
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 1,
|
||||||
|
runtimeProcessPendingCount: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
version: 2,
|
version: 2,
|
||||||
|
|
@ -2383,6 +2577,7 @@ describe('TeamProvisioningService', () => {
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 1,
|
||||||
|
runtimeProcessPendingCount: 1,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
version: 2,
|
version: 2,
|
||||||
|
|
@ -2403,6 +2598,7 @@ describe('TeamProvisioningService', () => {
|
||||||
runtimeAlive: true,
|
runtimeAlive: true,
|
||||||
bootstrapConfirmed: false,
|
bootstrapConfirmed: false,
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
|
livenessKind: 'runtime_process',
|
||||||
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
lastEvaluatedAt: '2026-04-22T12:00:00.000Z',
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
@ -2411,6 +2607,7 @@ describe('TeamProvisioningService', () => {
|
||||||
pendingCount: 1,
|
pendingCount: 1,
|
||||||
failedCount: 0,
|
failedCount: 0,
|
||||||
runtimeAlivePendingCount: 1,
|
runtimeAlivePendingCount: 1,
|
||||||
|
runtimeProcessPendingCount: 1,
|
||||||
},
|
},
|
||||||
teamLaunchState: 'partial_pending',
|
teamLaunchState: 'partial_pending',
|
||||||
}
|
}
|
||||||
|
|
@ -2420,6 +2617,24 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(message).not.toContain('/0');
|
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', () => {
|
it('uses the union of persisted expected members and persisted member entries for pending launch copy', () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const run = createMemberSpawnRun({
|
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 () => {
|
it('preserves richer persisted expectedMembers when OpenCode runtime liveness updates a stale snapshot', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const previousSnapshot = {
|
const previousSnapshot = {
|
||||||
|
|
@ -4356,6 +4647,107 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
expect(run.pendingMemberRestarts.has('bob')).toBe(false);
|
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 () => {
|
it('removes generated MCP config when createTeam spawn fails synchronously', async () => {
|
||||||
|
|
@ -5753,7 +6145,19 @@ describe('TeamProvisioningService', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const svc = new 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);
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
|
|
@ -6098,14 +6502,12 @@ describe('TeamProvisioningService', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).getLiveTeamAgentNames = vi.fn(() => new Set(['jack']));
|
|
||||||
|
|
||||||
const result = await svc.getMemberSpawnStatuses(teamName);
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
expect(result.statuses.jack).toMatchObject({
|
expect(result.statuses.jack).toMatchObject({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
launchState: 'failed_to_start',
|
launchState: 'failed_to_start',
|
||||||
runtimeAlive: true,
|
|
||||||
});
|
});
|
||||||
expect(result.statuses.jack?.error).toContain('requested model is not available');
|
expect(result.statuses.jack?.error).toContain('requested model is not available');
|
||||||
expect(result.statuses.jack?.hardFailureReason).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);
|
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();
|
const run = createMemberSpawnRun();
|
||||||
run.activeToolCalls.set('tool-agent-1', {
|
run.activeToolCalls.set('tool-agent-1', {
|
||||||
memberName: 'alice',
|
memberName: 'alice',
|
||||||
|
|
@ -6477,10 +6879,9 @@ describe('TeamProvisioningService', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
expect(run.memberSpawnStatuses.get('alice')).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
livenessSource: 'process',
|
|
||||||
hardFailure: false,
|
hardFailure: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -6629,6 +7030,7 @@ describe('TeamProvisioningService', () => {
|
||||||
{
|
{
|
||||||
alive: true,
|
alive: true,
|
||||||
model: 'gpt-5.2',
|
model: 'gpt-5.2',
|
||||||
|
livenessKind: 'runtime_process',
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
])
|
])
|
||||||
|
|
@ -6666,6 +7068,7 @@ describe('TeamProvisioningService', () => {
|
||||||
{
|
{
|
||||||
alive: true,
|
alive: true,
|
||||||
model: 'gpt-5.2',
|
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 () => {
|
it('does not clear an explicit restart failure just because the old runtime is still alive', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
(svc as any).getLiveTeamAgentRuntimeMetadata = vi.fn(
|
||||||
|
|
@ -6803,7 +7330,6 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(result.bob).toMatchObject({
|
expect(result.bob).toMatchObject({
|
||||||
status: 'error',
|
status: 'error',
|
||||||
launchState: 'failed_to_start',
|
launchState: 'failed_to_start',
|
||||||
runtimeAlive: true,
|
|
||||||
hardFailure: true,
|
hardFailure: true,
|
||||||
hardFailureReason: 'Teammate did not join within the launch grace window.',
|
hardFailureReason: 'Teammate did not join within the launch grace window.',
|
||||||
error: '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 teamName = 'suffixed-live-runtime-team';
|
||||||
const leadSessionId = 'lead-session';
|
const leadSessionId = 'lead-session';
|
||||||
writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['alice']);
|
writeLaunchConfig(teamName, '/Users/test/proj', leadSessionId, ['alice']);
|
||||||
|
|
@ -7039,14 +7565,26 @@ describe('TeamProvisioningService', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
const svc = new 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);
|
const result = await svc.getMemberSpawnStatuses(teamName);
|
||||||
|
|
||||||
expect(result.statuses.alice).toMatchObject({
|
expect(result.statuses.alice).toMatchObject({
|
||||||
status: 'online',
|
status: 'waiting',
|
||||||
launchState: 'runtime_pending_bootstrap',
|
launchState: 'runtime_pending_bootstrap',
|
||||||
runtimeAlive: true,
|
runtimeAlive: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -7105,7 +7643,7 @@ describe('TeamProvisioningService', () => {
|
||||||
2
|
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);
|
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
|
// @vitest-environment node
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
const getConfigMock = vi.fn();
|
|
||||||
|
|
||||||
vi.mock('@main/services/infrastructure/ConfigManager', () => ({
|
|
||||||
configManager: {
|
|
||||||
getConfig: () => getConfigMock(),
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
||||||
describe('cliFlavor', () => {
|
describe('cliFlavor', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
delete process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
delete process.env.CLAUDE_TEAM_CLI_FLAVOR;
|
||||||
|
|
@ -16,37 +8,20 @@ describe('cliFlavor', () => {
|
||||||
vi.clearAllMocks();
|
vi.clearAllMocks();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses multimodel runtime by default when config enables it', async () => {
|
it('uses multimodel runtime by default', async () => {
|
||||||
getConfigMock.mockReturnValue({
|
|
||||||
general: {
|
|
||||||
multimodelEnabled: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
|
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
|
||||||
|
|
||||||
expect(getConfiguredCliFlavor()).toBe('agent_teams_orchestrator');
|
expect(getConfiguredCliFlavor()).toBe('agent_teams_orchestrator');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses claude runtime when multimodel is disabled in config', async () => {
|
it('ignores the legacy persisted multimodel flag', async () => {
|
||||||
getConfigMock.mockReturnValue({
|
|
||||||
general: {
|
|
||||||
multimodelEnabled: false,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
|
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';
|
process.env.CLAUDE_TEAM_CLI_FLAVOR = 'claude';
|
||||||
getConfigMock.mockReturnValue({
|
|
||||||
general: {
|
|
||||||
multimodelEnabled: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
|
const { getConfiguredCliFlavor } = await import('@main/services/team/cliFlavor');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,7 @@ import { describe, expect, it } from 'vitest';
|
||||||
import {
|
import {
|
||||||
PROGRESS_LOG_TAIL_LINES,
|
PROGRESS_LOG_TAIL_LINES,
|
||||||
PROGRESS_OUTPUT_TAIL_PARTS,
|
PROGRESS_OUTPUT_TAIL_PARTS,
|
||||||
|
boundLaunchDiagnostics,
|
||||||
buildProgressAssistantOutput,
|
buildProgressAssistantOutput,
|
||||||
buildProgressLogsTail,
|
buildProgressLogsTail,
|
||||||
} from '../../../../src/main/services/team/progressPayload';
|
} from '../../../../src/main/services/team/progressPayload';
|
||||||
|
|
@ -75,3 +76,32 @@ describe('buildProgressAssistantOutput', () => {
|
||||||
expect(result!.split('\n\n')).toHaveLength(PROGRESS_OUTPUT_TAIL_PARTS);
|
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', () => {
|
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', () => {
|
it('should handle empty string', () => {
|
||||||
|
|
@ -177,6 +177,10 @@ describe('pathDecoder', () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should return true for valid Windows-style encoded path', () => {
|
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);
|
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();
|
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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
@ -340,8 +340,7 @@ describe('CLI status visibility during completed install state', () => {
|
||||||
expect(host.textContent).toContain('Login');
|
expect(host.textContent).toContain('Login');
|
||||||
|
|
||||||
const toggle = host.querySelector('[data-testid="multimodel-toggle"]');
|
const toggle = host.querySelector('[data-testid="multimodel-toggle"]');
|
||||||
expect(toggle).not.toBeNull();
|
expect(toggle).toBeNull();
|
||||||
expect(toggle?.hasAttribute('disabled')).toBe(false);
|
|
||||||
|
|
||||||
await act(async () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -3,13 +3,8 @@ import { createRoot } from 'react-dom/client';
|
||||||
import { afterEach, describe, expect, it, vi } from 'vitest';
|
import { afterEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
vi.mock('@renderer/components/ui/button', () => ({
|
vi.mock('@renderer/components/ui/button', () => ({
|
||||||
Button: ({
|
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) =>
|
||||||
children,
|
React.createElement('button', { type: 'button', onClick }, children),
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) => React.createElement('button', { type: 'button', onClick }, children),
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
|
vi.mock('@renderer/components/chat/viewers/MarkdownViewer', () => ({
|
||||||
|
|
@ -77,4 +72,117 @@ describe('ProvisioningProgressBlock', () => {
|
||||||
await Promise.resolve();
|
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.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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
@ -324,9 +324,9 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain('connecting');
|
expect(host.textContent).toContain('waiting for bootstrap');
|
||||||
expect(host.textContent).not.toContain('ready');
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
@ -430,4 +430,156 @@ describe('MemberCard starting-state visuals', () => {
|
||||||
await Promise.resolve();
|
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', () => ({
|
vi.mock('@renderer/components/ui/button', () => ({
|
||||||
Button: ({
|
Button: ({ children, onClick }: { children: React.ReactNode; onClick?: () => void }) =>
|
||||||
children,
|
|
||||||
onClick,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
onClick?: () => void;
|
|
||||||
}) =>
|
|
||||||
React.createElement(
|
React.createElement(
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
|
|
@ -33,7 +27,8 @@ vi.mock('@renderer/components/ui/button', () => ({
|
||||||
}));
|
}));
|
||||||
|
|
||||||
vi.mock('@renderer/components/ui/dialog', () => ({
|
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 }) =>
|
DialogContent: ({ children }: { children: React.ReactNode }) =>
|
||||||
React.createElement('div', null, children),
|
React.createElement('div', null, children),
|
||||||
DialogFooter: ({ children }: { children: React.ReactNode }) =>
|
DialogFooter: ({ children }: { children: React.ReactNode }) =>
|
||||||
|
|
@ -42,6 +37,15 @@ vi.mock('@renderer/components/ui/dialog', () => ({
|
||||||
React.createElement('div', null, children),
|
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', () => {
|
vi.mock('@renderer/components/ui/tabs', () => {
|
||||||
let currentValue = '';
|
let currentValue = '';
|
||||||
let currentOnValueChange: ((value: string) => void) | null = null;
|
let currentOnValueChange: ((value: string) => void) | null = null;
|
||||||
|
|
@ -60,14 +64,9 @@ vi.mock('@renderer/components/ui/tabs', () => {
|
||||||
currentOnValueChange = onValueChange ?? null;
|
currentOnValueChange = onValueChange ?? null;
|
||||||
return React.createElement('div', { 'data-tabs-value': value }, children);
|
return React.createElement('div', { 'data-tabs-value': value }, children);
|
||||||
},
|
},
|
||||||
TabsList: ({ children }: { children: React.ReactNode }) => React.createElement('div', null, children),
|
TabsList: ({ children }: { children: React.ReactNode }) =>
|
||||||
TabsTrigger: ({
|
React.createElement('div', null, children),
|
||||||
children,
|
TabsTrigger: ({ children, value }: { children: React.ReactNode; value: string }) =>
|
||||||
value,
|
|
||||||
}: {
|
|
||||||
children: React.ReactNode;
|
|
||||||
value: string;
|
|
||||||
}) =>
|
|
||||||
React.createElement(
|
React.createElement(
|
||||||
'button',
|
'button',
|
||||||
{
|
{
|
||||||
|
|
@ -77,13 +76,8 @@ vi.mock('@renderer/components/ui/tabs', () => {
|
||||||
},
|
},
|
||||||
children
|
children
|
||||||
),
|
),
|
||||||
TabsContent: ({
|
TabsContent: ({ children, value }: { children: React.ReactNode; value: string }) =>
|
||||||
children,
|
currentValue === value ? React.createElement('div', null, children) : null,
|
||||||
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', () => ({
|
vi.mock('@renderer/components/team/members/MemberDetailStats', () => ({
|
||||||
MemberDetailStats: ({ activityCount }: { activityCount: number }) =>
|
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', () => ({
|
vi.mock('@renderer/components/team/members/MemberTasksTab', () => ({
|
||||||
|
|
@ -210,4 +208,132 @@ describe('MemberDetailDialog activity count', () => {
|
||||||
await Promise.resolve();
|
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', () => ({
|
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 }) =>
|
DialogDescription: ({ children }: { children: React.ReactNode }) =>
|
||||||
React.createElement('div', null, children),
|
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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
@ -121,9 +122,9 @@ describe('MemberDetailHeader spawn-aware presence', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain('connecting');
|
expect(host.textContent).toContain('waiting for bootstrap');
|
||||||
expect(host.textContent).not.toContain('online');
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -45,6 +45,12 @@ const storeState = {
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
runtimeAlive: boolean;
|
runtimeAlive: boolean;
|
||||||
livenessSource?: string;
|
livenessSource?: string;
|
||||||
|
livenessKind?: string;
|
||||||
|
runtimeDiagnostic?: string;
|
||||||
|
runtimeDiagnosticSeverity?: string;
|
||||||
|
error?: string;
|
||||||
|
hardFailure?: boolean;
|
||||||
|
hardFailureReason?: string;
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
>,
|
>,
|
||||||
|
|
@ -52,6 +58,13 @@ const storeState = {
|
||||||
'northstar-core': undefined,
|
'northstar-core': undefined,
|
||||||
} as Record<string, unknown>,
|
} as Record<string, unknown>,
|
||||||
leadActivityByTeam: {},
|
leadActivityByTeam: {},
|
||||||
|
teamAgentRuntimeByTeam: {} as Record<
|
||||||
|
string,
|
||||||
|
{
|
||||||
|
runId: string | null;
|
||||||
|
members: Record<string, Record<string, unknown>>;
|
||||||
|
}
|
||||||
|
>,
|
||||||
openMemberProfile: vi.fn(),
|
openMemberProfile: vi.fn(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -61,7 +74,11 @@ vi.mock('@renderer/store', () => ({
|
||||||
|
|
||||||
vi.mock('@renderer/store/slices/teamSlice', () => ({
|
vi.mock('@renderer/store/slices/teamSlice', () => ({
|
||||||
getCurrentProvisioningProgressForTeam: () => storeState.progress,
|
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(
|
(state.selectedTeamName === teamName ? state.selectedTeamData : null)?.members.find(
|
||||||
(candidate) => candidate.name === memberName
|
(candidate) => candidate.name === memberName
|
||||||
) ?? null,
|
) ?? null,
|
||||||
|
|
@ -91,6 +108,15 @@ vi.mock('@renderer/components/ui/hover-card', () => ({
|
||||||
React.createElement('div', null, children),
|
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', () => ({
|
vi.mock('@renderer/components/team/members/CurrentTaskIndicator', () => ({
|
||||||
CurrentTaskIndicator: () => null,
|
CurrentTaskIndicator: () => null,
|
||||||
}));
|
}));
|
||||||
|
|
@ -116,6 +142,7 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
||||||
runtimeAlive: false,
|
runtimeAlive: false,
|
||||||
};
|
};
|
||||||
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined;
|
storeState.memberSpawnSnapshotsByTeam['northstar-core'] = undefined;
|
||||||
|
storeState.teamAgentRuntimeByTeam = {};
|
||||||
storeState.openMemberProfile.mockReset();
|
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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.progress = {
|
storeState.progress = {
|
||||||
runId: 'run-1',
|
runId: 'run-1',
|
||||||
|
|
@ -188,9 +215,9 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain('connecting');
|
expect(host.textContent).toContain('starting');
|
||||||
expect(host.textContent).not.toContain('online');
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
storeState.progress = null;
|
storeState.progress = null;
|
||||||
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
|
storeState.memberSpawnStatusesByTeam['northstar-core'].alice = {
|
||||||
|
|
@ -224,9 +251,9 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain('connecting');
|
expect(host.textContent).toContain('waiting for bootstrap');
|
||||||
expect(host.textContent).not.toContain('online');
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
@ -279,4 +306,96 @@ describe('MemberHoverCard spawn-aware presence', () => {
|
||||||
await Promise.resolve();
|
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', () => {
|
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 = '';
|
document.body.innerHTML = '';
|
||||||
useStore.setState({
|
|
||||||
selectedTeamName: null,
|
|
||||||
selectedTeamData: null,
|
|
||||||
teamDataCacheByName: {},
|
|
||||||
} as never);
|
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
@ -138,45 +141,48 @@ describe('GraphNodePopover spawn badge labels', () => {
|
||||||
|
|
||||||
it('reuses launch-aware presence semantics from cached team data', async () => {
|
it('reuses launch-aware presence semantics from cached team data', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
useStore.setState({
|
await act(async () => {
|
||||||
teamDataCacheByName: {
|
useStore.setState({
|
||||||
'northstar-core': {
|
teamDataCacheByName: {
|
||||||
teamName: 'northstar-core',
|
'northstar-core': {
|
||||||
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
teamName: 'northstar-core',
|
||||||
members: [
|
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
||||||
{
|
members: [
|
||||||
name: 'alice',
|
{
|
||||||
status: 'active',
|
name: 'alice',
|
||||||
currentTaskId: null,
|
status: 'active',
|
||||||
taskCount: 0,
|
currentTaskId: null,
|
||||||
lastActiveAt: null,
|
taskCount: 0,
|
||||||
messageCount: 0,
|
lastActiveAt: null,
|
||||||
agentType: 'reviewer',
|
messageCount: 0,
|
||||||
providerId: 'codex',
|
agentType: 'reviewer',
|
||||||
},
|
providerId: 'codex',
|
||||||
],
|
},
|
||||||
tasks: [],
|
],
|
||||||
messages: [],
|
tasks: [],
|
||||||
kanbanState: { teamName: 'northstar-core', reviewers: [], tasks: {} },
|
messages: [],
|
||||||
processes: [],
|
kanbanState: { teamName: 'northstar-core', reviewers: [], tasks: {} },
|
||||||
isAlive: true,
|
processes: [],
|
||||||
},
|
isAlive: true,
|
||||||
},
|
|
||||||
memberSpawnStatusesByTeam: {
|
|
||||||
'northstar-core': {
|
|
||||||
alice: {
|
|
||||||
status: 'online',
|
|
||||||
launchState: 'runtime_pending_bootstrap',
|
|
||||||
livenessSource: 'process',
|
|
||||||
runtimeAlive: true,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
memberSpawnStatusesByTeam: {
|
||||||
memberSpawnSnapshotsByTeam: {},
|
'northstar-core': {
|
||||||
currentProvisioningRunIdByTeam: {},
|
alice: {
|
||||||
provisioningRuns: {},
|
status: 'online',
|
||||||
leadActivityByTeam: {},
|
launchState: 'runtime_pending_bootstrap',
|
||||||
} as never);
|
livenessSource: 'process',
|
||||||
|
runtimeAlive: true,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
memberSpawnSnapshotsByTeam: {},
|
||||||
|
currentProvisioningRunIdByTeam: {},
|
||||||
|
provisioningRuns: {},
|
||||||
|
leadActivityByTeam: {},
|
||||||
|
} as never);
|
||||||
|
await Promise.resolve();
|
||||||
|
});
|
||||||
|
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
document.body.appendChild(host);
|
document.body.appendChild(host);
|
||||||
|
|
@ -193,7 +199,7 @@ describe('GraphNodePopover spawn badge labels', () => {
|
||||||
await Promise.resolve();
|
await Promise.resolve();
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(host.textContent).toContain('connecting');
|
expect(host.textContent).toContain('waiting for bootstrap');
|
||||||
expect(host.textContent).not.toContain('Idle');
|
expect(host.textContent).not.toContain('Idle');
|
||||||
|
|
||||||
await act(async () => {
|
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 () => {
|
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);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
useStore.setState({
|
await act(async () => {
|
||||||
selectedTeamName: 'northstar-core',
|
useStore.setState({
|
||||||
selectedTeamData: {
|
selectedTeamName: 'northstar-core',
|
||||||
teamName: 'northstar-core',
|
selectedTeamData: {
|
||||||
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': {
|
|
||||||
teamName: 'northstar-core',
|
teamName: 'northstar-core',
|
||||||
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
config: { name: 'Northstar', members: [], projectPath: '/repo' },
|
||||||
tasks: [
|
tasks: [
|
||||||
|
|
@ -283,8 +251,49 @@ describe('GraphNodePopover spawn badge labels', () => {
|
||||||
},
|
},
|
||||||
processes: [],
|
processes: [],
|
||||||
},
|
},
|
||||||
},
|
teamDataCacheByName: {
|
||||||
} as never);
|
'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 onOpenTaskDetail = vi.fn();
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -1136,8 +1136,8 @@ describe('TeamGraphAdapter particles', () => {
|
||||||
);
|
);
|
||||||
|
|
||||||
expect(findNode(graph, 'member:my-team:alice')).toMatchObject({
|
expect(findNode(graph, 'member:my-team:alice')).toMatchObject({
|
||||||
launchVisualState: 'runtime_pending',
|
launchVisualState: 'waiting',
|
||||||
launchStatusLabel: 'connecting',
|
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