chore(team): checkpoint current frontend work
This commit is contained in:
parent
9d7542e9c4
commit
f6e95f5b2f
64 changed files with 4620 additions and 413 deletions
|
|
@ -66,6 +66,33 @@ function normalizeActorKey(value) {
|
|||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function openReviewInterval(task, reviewer, timestamp = new Date().toISOString()) {
|
||||
const reviewerName = typeof reviewer === 'string' && reviewer.trim() ? reviewer.trim() : '';
|
||||
if (!reviewerName) return false;
|
||||
const reviewerKey = normalizeActorKey(reviewerName);
|
||||
const intervals = Array.isArray(task.reviewIntervals) ? [...task.reviewIntervals] : [];
|
||||
const hasOpenForReviewer = intervals.some(
|
||||
(interval) => !interval.completedAt && normalizeActorKey(interval.reviewer) === reviewerKey
|
||||
);
|
||||
if (hasOpenForReviewer) {
|
||||
task.reviewIntervals = intervals;
|
||||
return false;
|
||||
}
|
||||
task.reviewIntervals = [...intervals, { reviewer: reviewerName, startedAt: timestamp }];
|
||||
return true;
|
||||
}
|
||||
|
||||
function closeReviewIntervals(task, timestamp = new Date().toISOString()) {
|
||||
if (!Array.isArray(task.reviewIntervals)) return false;
|
||||
let changed = false;
|
||||
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||
if (interval.completedAt) return interval;
|
||||
changed = true;
|
||||
return { ...interval, completedAt: timestamp };
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function resolveKnownActorName(context, value, label) {
|
||||
const actor = typeof value === 'string' && value.trim() ? value.trim() : '';
|
||||
if (!actor) return null;
|
||||
|
|
@ -130,7 +157,9 @@ function getReviewStartActor(context, task, flags) {
|
|||
return resolveKnownActorName(context, kanbanEntry.reviewer, 'reviewer');
|
||||
}
|
||||
|
||||
throw new Error(`review_start requires from when task #${task.displayId || task.id} has no assigned reviewer`);
|
||||
throw new Error(
|
||||
`review_start requires from when task #${task.displayId || task.id} has no assigned reviewer`
|
||||
);
|
||||
}
|
||||
|
||||
function getLatestReviewStartedActor(task) {
|
||||
|
|
@ -155,18 +184,25 @@ function getLatestReviewStartedActor(task) {
|
|||
|
||||
function getReviewDecisionActor(context, task, flags, actionName) {
|
||||
const explicit = resolveKnownActorName(context, flags.from, 'review actor');
|
||||
const startedActor = tryResolveKnownActorName(context, getLatestReviewStartedActor(task), 'review actor');
|
||||
const assignedReviewer = tryResolveKnownActorName(context, getLatestReviewRequestedReviewer(task), 'reviewer');
|
||||
const startedActor = tryResolveKnownActorName(
|
||||
context,
|
||||
getLatestReviewStartedActor(task),
|
||||
'review actor'
|
||||
);
|
||||
const assignedReviewer = tryResolveKnownActorName(
|
||||
context,
|
||||
getLatestReviewRequestedReviewer(task),
|
||||
'reviewer'
|
||||
);
|
||||
const inferredActor =
|
||||
startedActor &&
|
||||
(!assignedReviewer ||
|
||||
resolveActorIdentityKey(context, startedActor) === resolveActorIdentityKey(context, assignedReviewer))
|
||||
resolveActorIdentityKey(context, startedActor) ===
|
||||
resolveActorIdentityKey(context, assignedReviewer))
|
||||
? startedActor
|
||||
: assignedReviewer;
|
||||
const actor =
|
||||
explicit ||
|
||||
inferredActor ||
|
||||
resolveKnownActorName(context, 'team-lead', 'review actor');
|
||||
explicit || inferredActor || resolveKnownActorName(context, 'team-lead', 'review actor');
|
||||
assertMatchesAssignedReviewer(context, task, actor, actionName);
|
||||
return actor;
|
||||
}
|
||||
|
|
@ -176,12 +212,16 @@ function assertReviewTransitionAllowed(context, task, transitionName) {
|
|||
throw new Error(`Task #${task.displayId || task.id} is deleted`);
|
||||
}
|
||||
if (task.status !== 'completed') {
|
||||
throw new Error(`Task #${task.displayId || task.id} must be completed before ${transitionName}`);
|
||||
throw new Error(
|
||||
`Task #${task.displayId || task.id} must be completed before ${transitionName}`
|
||||
);
|
||||
}
|
||||
|
||||
const reviewState = getEffectiveReviewState(context, task);
|
||||
if (reviewState !== 'review') {
|
||||
throw new Error(`Task #${task.displayId || task.id} must be in review before ${transitionName}`);
|
||||
throw new Error(
|
||||
`Task #${task.displayId || task.id} must be in review before ${transitionName}`
|
||||
);
|
||||
}
|
||||
return reviewState;
|
||||
}
|
||||
|
|
@ -223,9 +263,14 @@ function startReview(context, taskId, flags = {}) {
|
|||
|
||||
if (latestReviewEvent && latestReviewEvent.type === 'review_started') {
|
||||
assertReviewTransitionAllowed(context, task, 'starting review');
|
||||
const existingActor = typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : '';
|
||||
const existingActor =
|
||||
typeof latestReviewEvent.actor === 'string' ? latestReviewEvent.actor.trim() : '';
|
||||
const existingActorValid = existingActor
|
||||
? Boolean(runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, { allowLeadAliases: true }))
|
||||
? Boolean(
|
||||
runtimeHelpers.resolveExplicitTeamMemberName(context.paths, existingActor, {
|
||||
allowLeadAliases: true,
|
||||
})
|
||||
)
|
||||
: false;
|
||||
const assignedReviewer = tryResolveKnownActorName(
|
||||
context,
|
||||
|
|
@ -235,7 +280,8 @@ function startReview(context, taskId, flags = {}) {
|
|||
const existingMatchesAssigned =
|
||||
!assignedReviewer ||
|
||||
(existingActorValid &&
|
||||
resolveActorIdentityKey(context, existingActor) === resolveActorIdentityKey(context, assignedReviewer));
|
||||
resolveActorIdentityKey(context, existingActor) ===
|
||||
resolveActorIdentityKey(context, assignedReviewer));
|
||||
const requestedActor =
|
||||
typeof flags.from === 'string' && flags.from.trim()
|
||||
? getReviewStartActor(context, task, flags)
|
||||
|
|
@ -244,38 +290,52 @@ function startReview(context, taskId, flags = {}) {
|
|||
existingActorValid &&
|
||||
existingMatchesAssigned &&
|
||||
requestedActor &&
|
||||
resolveActorIdentityKey(context, existingActor) !== resolveActorIdentityKey(context, requestedActor)
|
||||
resolveActorIdentityKey(context, existingActor) !==
|
||||
resolveActorIdentityKey(context, requestedActor)
|
||||
) {
|
||||
throw new Error(`Task #${task.displayId || task.id} review is already started by ${existingActor}`);
|
||||
throw new Error(
|
||||
`Task #${task.displayId || task.id} review is already started by ${existingActor}`
|
||||
);
|
||||
}
|
||||
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
|
||||
if (!existingActorValid || !existingMatchesAssigned) {
|
||||
const repairedActor = requestedActor || getReviewStartActor(context, task, flags);
|
||||
const timestamp = new Date().toISOString();
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
openReviewInterval(t, repairedActor, timestamp);
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_started',
|
||||
from: prevReviewState,
|
||||
to: 'review',
|
||||
actor: repairedActor,
|
||||
timestamp,
|
||||
});
|
||||
t.reviewState = 'review';
|
||||
return t;
|
||||
});
|
||||
} else {
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
openReviewInterval(t, existingActor);
|
||||
return t;
|
||||
});
|
||||
}
|
||||
return { ok: true, taskId: task.id, displayId: task.displayId, column: 'review' };
|
||||
}
|
||||
|
||||
assertReviewTransitionAllowed(context, task, 'starting review');
|
||||
const from = getReviewStartActor(context, task, flags);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
try {
|
||||
kanban.setKanbanColumn(context, task.id, 'review', { transition: 'start_review' });
|
||||
tasks.updateTask(context, task.id, (t) => {
|
||||
openReviewInterval(t, from, timestamp);
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_started',
|
||||
from: prevReviewState,
|
||||
to: 'review',
|
||||
actor: from,
|
||||
timestamp,
|
||||
});
|
||||
t.reviewState = 'review';
|
||||
return t;
|
||||
|
|
@ -285,7 +345,10 @@ function startReview(context, taskId, flags = {}) {
|
|||
try {
|
||||
kanban.clearKanban(context, task.id, { transition: 'rollback' });
|
||||
} catch (rollbackError) {
|
||||
warnNonCritical(`[review] rollback failed while starting review for ${task.id}`, rollbackError);
|
||||
warnNonCritical(
|
||||
`[review] rollback failed while starting review for ${task.id}`,
|
||||
rollbackError
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -296,17 +359,23 @@ function requestReview(context, taskId, flags = {}) {
|
|||
const { task, reviewer, from, leadSessionId } = withTeamBoardLock(context.paths, () => {
|
||||
const currentTask = tasks.getTask(context, taskId);
|
||||
if (currentTask.status !== 'completed') {
|
||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before review`);
|
||||
throw new Error(
|
||||
`Task #${currentTask.displayId || currentTask.id} must be completed before review`
|
||||
);
|
||||
}
|
||||
|
||||
const nextFrom =
|
||||
resolveKnownActorName(context, flags.from, 'review requester') ||
|
||||
resolveKnownActorName(context, 'team-lead', 'review requester');
|
||||
const rawReviewer = getReviewer(context, flags);
|
||||
const nextReviewer = rawReviewer ? resolveKnownActorName(context, rawReviewer, 'reviewer') : null;
|
||||
const nextReviewer = rawReviewer
|
||||
? resolveKnownActorName(context, rawReviewer, 'reviewer')
|
||||
: null;
|
||||
const prevReviewState = getEffectiveReviewState(context, currentTask);
|
||||
if (prevReviewState === 'approved') {
|
||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`);
|
||||
throw new Error(
|
||||
`Task #${currentTask.displayId || currentTask.id} is already approved; reopen work before requesting another review`
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
|
|
@ -326,7 +395,10 @@ function requestReview(context, taskId, flags = {}) {
|
|||
try {
|
||||
kanban.clearKanban(context, currentTask.id, { transition: 'rollback' });
|
||||
} catch (rollbackError) {
|
||||
warnNonCritical(`[review] rollback failed while requesting review for ${currentTask.id}`, rollbackError);
|
||||
warnNonCritical(
|
||||
`[review] rollback failed while requesting review for ${currentTask.id}`,
|
||||
rollbackError
|
||||
);
|
||||
}
|
||||
throw error;
|
||||
}
|
||||
|
|
@ -383,7 +455,9 @@ function approveReview(context, taskId, flags = {}) {
|
|||
|
||||
if (prevReviewState === 'approved') {
|
||||
if (currentTask.status !== 'completed') {
|
||||
throw new Error(`Task #${currentTask.displayId || currentTask.id} must be completed before approval`);
|
||||
throw new Error(
|
||||
`Task #${currentTask.displayId || currentTask.id} must be completed before approval`
|
||||
);
|
||||
}
|
||||
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
|
||||
return {
|
||||
|
|
@ -399,15 +473,18 @@ function approveReview(context, taskId, flags = {}) {
|
|||
}
|
||||
|
||||
assertReviewTransitionAllowed(context, currentTask, 'approval');
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
kanban.setKanbanColumn(context, currentTask.id, 'approved', { transition: 'approve_review' });
|
||||
tasks.updateTask(context, currentTask.id, (t) => {
|
||||
closeReviewIntervals(t, timestamp);
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_approved',
|
||||
from: prevReviewState,
|
||||
to: 'approved',
|
||||
...(nextNote ? { note: nextNote } : {}),
|
||||
actor: nextFrom,
|
||||
timestamp,
|
||||
});
|
||||
t.reviewState = 'approved';
|
||||
return t;
|
||||
|
|
@ -472,15 +549,22 @@ function requestChanges(context, taskId, flags = {}) {
|
|||
typeof flags.comment === 'string' && flags.comment.trim()
|
||||
? flags.comment.trim()
|
||||
: 'Reviewer requested changes.';
|
||||
const prevReviewState = assertReviewTransitionAllowed(context, currentTask, 'requesting changes');
|
||||
const prevReviewState = assertReviewTransitionAllowed(
|
||||
context,
|
||||
currentTask,
|
||||
'requesting changes'
|
||||
);
|
||||
const timestamp = new Date().toISOString();
|
||||
|
||||
tasks.updateTask(context, currentTask.id, (t) => {
|
||||
closeReviewIntervals(t, timestamp);
|
||||
t.historyEvents = tasks.appendHistoryEvent(t.historyEvents, {
|
||||
type: 'review_changes_requested',
|
||||
from: prevReviewState,
|
||||
to: 'needsFix',
|
||||
...(nextComment ? { note: nextComment } : {}),
|
||||
actor: nextFrom,
|
||||
timestamp,
|
||||
});
|
||||
t.reviewState = 'needsFix';
|
||||
return t;
|
||||
|
|
|
|||
|
|
@ -66,7 +66,9 @@ function normalizeTask(rawTask, filePath) {
|
|||
};
|
||||
|
||||
if (!TASK_STATUSES.has(String(task.status || '').trim())) {
|
||||
throw new Error(`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`);
|
||||
throw new Error(
|
||||
`Invalid task status "${String(task.status || '')}"${filePath ? `: ${filePath}` : ''}`
|
||||
);
|
||||
}
|
||||
task.status = String(task.status).trim();
|
||||
|
||||
|
|
@ -121,10 +123,14 @@ function listTaskRows(paths, options = {}) {
|
|||
}
|
||||
|
||||
tasks.sort((a, b) => {
|
||||
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
||||
const byDisplay = String(a.displayId || a.id).localeCompare(
|
||||
String(b.displayId || b.id),
|
||||
undefined,
|
||||
{
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
}
|
||||
);
|
||||
if (byDisplay !== 0) return byDisplay;
|
||||
return String(a.id).localeCompare(String(b.id), undefined, {
|
||||
numeric: true,
|
||||
|
|
@ -144,7 +150,9 @@ function listTasks(paths, options = {}) {
|
|||
}
|
||||
|
||||
function resolveTaskRef(paths, taskRef, options = {}) {
|
||||
const normalizedRef = String(taskRef || '').trim().replace(/^#/, '');
|
||||
const normalizedRef = String(taskRef || '')
|
||||
.trim()
|
||||
.replace(/^#/, '');
|
||||
if (!normalizedRef) {
|
||||
throw new Error('Missing taskId');
|
||||
}
|
||||
|
|
@ -168,9 +176,7 @@ function resolveTaskRef(paths, taskRef, options = {}) {
|
|||
}
|
||||
|
||||
const byDisplay = tasks.find(
|
||||
(task) =>
|
||||
task.displayId === normalizedRef &&
|
||||
(includeDeleted || task.status !== 'deleted')
|
||||
(task) => task.displayId === normalizedRef && (includeDeleted || task.status !== 'deleted')
|
||||
);
|
||||
if (byDisplay) {
|
||||
return byDisplay.id;
|
||||
|
|
@ -195,6 +201,17 @@ function appendHistoryEvent(events, event) {
|
|||
return list;
|
||||
}
|
||||
|
||||
function closeOpenReviewIntervals(task, timestamp) {
|
||||
if (!Array.isArray(task.reviewIntervals)) return false;
|
||||
let changed = false;
|
||||
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||
if (interval.completedAt) return interval;
|
||||
changed = true;
|
||||
return { ...interval, completedAt: timestamp };
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function normalizeStatus(status) {
|
||||
const normalized = String(status || '').trim();
|
||||
return TASK_STATUSES.has(normalized) ? normalized : null;
|
||||
|
|
@ -204,7 +221,10 @@ function parseRelationshipList(paths, value) {
|
|||
const rawValues = Array.isArray(value)
|
||||
? value
|
||||
: typeof value === 'string'
|
||||
? value.split(',').map((entry) => entry.trim()).filter(Boolean)
|
||||
? value
|
||||
.split(',')
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: [];
|
||||
|
||||
return rawValues.map((entry) => resolveTaskRef(paths, entry));
|
||||
|
|
@ -248,7 +268,9 @@ function pickUniqueDisplayId(paths, canonicalId, explicitDisplayId) {
|
|||
? explicitDisplayId.trim()
|
||||
: deriveDisplayId(canonicalId);
|
||||
|
||||
const existing = new Set(listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id)));
|
||||
const existing = new Set(
|
||||
listRawTasks(paths).map((task) => task.displayId || deriveDisplayId(task.id))
|
||||
);
|
||||
if (!existing.has(preferred)) {
|
||||
return preferred;
|
||||
}
|
||||
|
|
@ -310,7 +332,9 @@ function createTask(paths, input = {}) {
|
|||
? input.createdBy.trim()
|
||||
: undefined;
|
||||
const createdAt =
|
||||
typeof input.createdAt === 'string' && input.createdAt.trim() ? input.createdAt.trim() : nowIso();
|
||||
typeof input.createdAt === 'string' && input.createdAt.trim()
|
||||
? input.createdAt.trim()
|
||||
: nowIso();
|
||||
const status = computeInitialStatus(paths, input, owner, blockedByIds);
|
||||
const displayId = pickUniqueDisplayId(paths, canonicalId, input.displayId);
|
||||
|
||||
|
|
@ -429,7 +453,10 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|||
if (task.status === status) {
|
||||
if (status === 'deleted' || status === 'in_progress') {
|
||||
task.reviewState = 'none';
|
||||
} else if (status === 'pending' && normalizeTaskReviewState(task.reviewState) !== 'needsFix') {
|
||||
} else if (
|
||||
status === 'pending' &&
|
||||
normalizeTaskReviewState(task.reviewState) !== 'needsFix'
|
||||
) {
|
||||
task.reviewState = 'none';
|
||||
}
|
||||
return task;
|
||||
|
|
@ -447,6 +474,9 @@ function setTaskStatus(paths, taskRef, nextStatus, actor) {
|
|||
lastInterval.completedAt = timestamp;
|
||||
}
|
||||
}
|
||||
if (status === 'pending' || status === 'in_progress' || status === 'deleted') {
|
||||
closeOpenReviewIntervals(task, timestamp);
|
||||
}
|
||||
|
||||
task.workIntervals = workIntervals.length > 0 ? workIntervals : undefined;
|
||||
task.historyEvents = appendHistoryEvent(task.historyEvents, {
|
||||
|
|
@ -531,16 +561,16 @@ function addTaskComment(paths, taskRef, text, options = {}) {
|
|||
const comment = {
|
||||
id: options.id || crypto.randomUUID(),
|
||||
author:
|
||||
typeof options.author === 'string' && options.author.trim()
|
||||
? options.author.trim()
|
||||
: 'user',
|
||||
typeof options.author === 'string' && options.author.trim() ? options.author.trim() : 'user',
|
||||
text,
|
||||
createdAt:
|
||||
typeof options.createdAt === 'string' && options.createdAt.trim()
|
||||
? options.createdAt.trim()
|
||||
: nowIso(),
|
||||
type: options.type || 'regular',
|
||||
...(normalizeTaskRefs(options.taskRefs) ? { taskRefs: normalizeTaskRefs(options.taskRefs) } : {}),
|
||||
...(normalizeTaskRefs(options.taskRefs)
|
||||
? { taskRefs: normalizeTaskRefs(options.taskRefs) }
|
||||
: {}),
|
||||
...(Array.isArray(options.attachments) && options.attachments.length > 0
|
||||
? { attachments: options.attachments }
|
||||
: {}),
|
||||
|
|
@ -711,10 +741,14 @@ function getTaskFreshness(task) {
|
|||
function compareTasksByFreshness(a, b) {
|
||||
const freshnessDiff = getTaskFreshness(b) - getTaskFreshness(a);
|
||||
if (freshnessDiff !== 0) return freshnessDiff;
|
||||
const byDisplay = String(a.displayId || a.id).localeCompare(String(b.displayId || b.id), undefined, {
|
||||
const byDisplay = String(a.displayId || a.id).localeCompare(
|
||||
String(b.displayId || b.id),
|
||||
undefined,
|
||||
{
|
||||
numeric: true,
|
||||
sensitivity: 'base',
|
||||
});
|
||||
}
|
||||
);
|
||||
if (byDisplay !== 0) return byDisplay;
|
||||
return String(a.id).localeCompare(String(b.id), undefined, {
|
||||
numeric: true,
|
||||
|
|
@ -756,7 +790,9 @@ function formatTaskBriefing(paths, teamName, memberName) {
|
|||
in_progress: activeTasks.filter((task) => task.status === 'in_progress'),
|
||||
needs_fix: activeTasks.filter((task) => {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
return task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix';
|
||||
return (
|
||||
task.status !== 'in_progress' && getEffectiveReviewState(kanbanEntry, task) === 'needsFix'
|
||||
);
|
||||
}),
|
||||
pending: activeTasks.filter((task) => {
|
||||
const kanbanEntry = kanbanState.tasks ? kanbanState.tasks[task.id] : undefined;
|
||||
|
|
|
|||
|
|
@ -54,7 +54,10 @@ describe('agent-teams-controller API', () => {
|
|||
const address = server.address();
|
||||
return {
|
||||
baseUrl: `http://127.0.0.1:${address.port}`,
|
||||
close: async () => await new Promise((resolve, reject) => server.close((error) => (error ? reject(error) : resolve()))),
|
||||
close: async () =>
|
||||
await new Promise((resolve, reject) =>
|
||||
server.close((error) => (error ? reject(error) : resolve()))
|
||||
),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
@ -146,8 +149,12 @@ describe('agent-teams-controller API', () => {
|
|||
expect(briefing).toContain('Implement carefully');
|
||||
expect(briefing).toContain('Working directory: /tmp/project-x');
|
||||
expect(briefing).toContain('Task briefing for bob:');
|
||||
expect(briefing).toContain('Use task_briefing as your primary working queue whenever you need to see assigned work.');
|
||||
expect(briefing).toContain('Use task_list only to search/browse inventory rows, not as your working queue.');
|
||||
expect(briefing).toContain(
|
||||
'Use task_briefing as your primary working queue whenever you need to see assigned work.'
|
||||
);
|
||||
expect(briefing).toContain(
|
||||
'Use task_list only to search/browse inventory rows, not as your working queue.'
|
||||
);
|
||||
expect(briefing).toContain('member_work_sync_status and member_work_sync_report');
|
||||
expect(briefing).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.'
|
||||
|
|
@ -175,9 +182,7 @@ describe('agent-teams-controller API', () => {
|
|||
);
|
||||
expect(briefing).toContain('OpenCode visible messaging rule: call agent-teams_message_send');
|
||||
expect(briefing).toContain('OpenCode bootstrap silence rule');
|
||||
expect(briefing).toContain(
|
||||
'If it shows no actionable tasks, stop and wait silently.'
|
||||
);
|
||||
expect(briefing).toContain('If it shows no actionable tasks, stop and wait silently.');
|
||||
expect(briefing).toContain(
|
||||
'agent-teams_message_send { teamName: "my-team", to: "alice", from: "bob"'
|
||||
);
|
||||
|
|
@ -478,7 +483,10 @@ describe('agent-teams-controller API', () => {
|
|||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
controller.tasks.addTaskComment(activeTask.id, { from: 'bob', text: 'Resumed work with latest context.' });
|
||||
controller.tasks.addTaskComment(activeTask.id, {
|
||||
from: 'bob',
|
||||
text: 'Resumed work with latest context.',
|
||||
});
|
||||
const needsFixTask = controller.tasks.createTask({
|
||||
subject: 'Fix after review',
|
||||
owner: 'bob',
|
||||
|
|
@ -517,7 +525,9 @@ describe('agent-teams-controller API', () => {
|
|||
expect(ownerInbox[0].text).toContain('task_get');
|
||||
expect(ownerInbox[0].text).toContain('task_start');
|
||||
expect(ownerInbox[0].text).toContain('task_add_comment');
|
||||
expect(ownerInbox[0].text).toContain('If you are idle and this task is ready to start, start it now.');
|
||||
expect(ownerInbox[0].text).toContain(
|
||||
'If you are idle and this task is ready to start, start it now.'
|
||||
);
|
||||
expect(ownerInbox[0].text).toContain(
|
||||
'If you are busy, blocked, or still need more context, immediately add a short task comment'
|
||||
);
|
||||
|
|
@ -527,7 +537,9 @@ describe('agent-teams-controller API', () => {
|
|||
expect(ownerInbox[0].text).toContain('Check the migration plan first.');
|
||||
expect(ownerInbox[0].leadSessionId).toBe('lead-session-1');
|
||||
expect(ownerInbox[3].summary).toContain(`#${reassignedTask.displayId}`);
|
||||
expect(ownerInbox[3].text).toContain('If you are idle and this task is ready to start, start it now.');
|
||||
expect(ownerInbox[3].text).toContain(
|
||||
'If you are idle and this task is ready to start, start it now.'
|
||||
);
|
||||
expect(ownerInbox[3].text).toContain('task_add_comment');
|
||||
|
||||
const briefing = await controller.tasks.taskBriefing('bob');
|
||||
|
|
@ -549,9 +561,7 @@ describe('agent-teams-controller API', () => {
|
|||
expect(briefing).toContain(`#${reviewTask.displayId}`);
|
||||
expect(briefing).toContain('reason=review_reviewer_missing');
|
||||
expect(briefing).toContain(`#${completedTask.displayId}`);
|
||||
expect(briefing).not.toContain(
|
||||
'Completed task description should stay out of compact rows'
|
||||
);
|
||||
expect(briefing).not.toContain('Completed task description should stay out of compact rows');
|
||||
expect(briefing).toContain(`#${approvedTask.displayId}`);
|
||||
expect(briefing).toContain('Counters: actionable=4, awareness=3');
|
||||
});
|
||||
|
|
@ -709,12 +719,7 @@ describe('agent-teams-controller API', () => {
|
|||
const firstEvent = restored.historyEvents[0];
|
||||
expect(firstEvent.status).toBe('pending');
|
||||
const statusChanges = restored.historyEvents.slice(1).map((e) => e.to);
|
||||
expect(statusChanges).toEqual([
|
||||
'in_progress',
|
||||
'completed',
|
||||
'deleted',
|
||||
'pending',
|
||||
]);
|
||||
expect(statusChanges).toEqual(['in_progress', 'completed', 'deleted', 'pending']);
|
||||
});
|
||||
|
||||
it('tracks owner assignment history without duplicate same-owner events', () => {
|
||||
|
|
@ -804,6 +809,10 @@ describe('agent-teams-controller API', () => {
|
|||
expect(reviewEvent.from).toBe('review');
|
||||
expect(reviewEvent.to).toBe('review');
|
||||
expect(reviewEvent.actor).toBe('alice');
|
||||
expect(updatedTask.reviewIntervals).toHaveLength(1);
|
||||
expect(updatedTask.reviewIntervals[0].reviewer).toBe('alice');
|
||||
expect(updatedTask.reviewIntervals[0].startedAt).toBeTruthy();
|
||||
expect(updatedTask.reviewIntervals[0].completedAt).toBeUndefined();
|
||||
|
||||
// Idempotent: calling again should also succeed without duplicate events
|
||||
const again = controller.review.startReview(task.id, { from: 'alice' });
|
||||
|
|
@ -811,6 +820,35 @@ describe('agent-teams-controller API', () => {
|
|||
const reloaded = controller.tasks.getTask(task.id);
|
||||
const startedEvents = reloaded.historyEvents.filter((e) => e.type === 'review_started');
|
||||
expect(startedEvents).toHaveLength(1);
|
||||
expect(reloaded.reviewIntervals).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('closes review intervals when review is approved or changes are requested', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const approvedTask = controller.tasks.createTask({ subject: 'Approve review', owner: 'bob' });
|
||||
|
||||
controller.tasks.completeTask(approvedTask.id, 'bob');
|
||||
controller.review.requestReview(approvedTask.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
controller.review.startReview(approvedTask.id, { from: 'alice' });
|
||||
const approved = controller.review.approveReview(approvedTask.id, { from: 'alice' });
|
||||
|
||||
expect(approved.reviewIntervals).toHaveLength(1);
|
||||
expect(approved.reviewIntervals[0].reviewer).toBe('alice');
|
||||
expect(approved.reviewIntervals[0].completedAt).toBeTruthy();
|
||||
|
||||
const changesTask = controller.tasks.createTask({ subject: 'Request changes', owner: 'bob' });
|
||||
controller.tasks.completeTask(changesTask.id, 'bob');
|
||||
controller.review.requestReview(changesTask.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
controller.review.startReview(changesTask.id, { from: 'alice' });
|
||||
const changed = controller.review.requestChanges(changesTask.id, {
|
||||
from: 'alice',
|
||||
comment: 'Needs a fix.',
|
||||
});
|
||||
|
||||
expect(changed.reviewIntervals).toHaveLength(1);
|
||||
expect(changed.reviewIntervals[0].reviewer).toBe('alice');
|
||||
expect(changed.reviewIntervals[0].completedAt).toBeTruthy();
|
||||
});
|
||||
|
||||
it('records review_start after review_request and surfaces review_in_progress for the reviewer', async () => {
|
||||
|
|
@ -841,7 +879,10 @@ describe('agent-teams-controller API', () => {
|
|||
it('uses the assigned reviewer when review_start omits from', async () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Queued for implicit reviewer', owner: 'bob' });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Queued for implicit reviewer',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
|
|
@ -866,15 +907,23 @@ describe('agent-teams-controller API', () => {
|
|||
'must be completed before approval'
|
||||
);
|
||||
|
||||
const completedTask = controller.tasks.createTask({ subject: 'Completed but not review', owner: 'bob' });
|
||||
const completedTask = controller.tasks.createTask({
|
||||
subject: 'Completed but not review',
|
||||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
expect(() =>
|
||||
controller.review.requestChanges(completedTask.id, { from: 'alice', comment: 'Fix it' })
|
||||
).toThrow('must be in review before requesting changes');
|
||||
|
||||
const deletedTask = controller.tasks.createTask({ subject: 'Deleted review task', owner: 'bob' });
|
||||
const deletedTask = controller.tasks.createTask({
|
||||
subject: 'Deleted review task',
|
||||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.softDeleteTask(deletedTask.id, 'bob');
|
||||
expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow('is deleted');
|
||||
expect(() => controller.review.approveReview(deletedTask.id, { from: 'alice' })).toThrow(
|
||||
'is deleted'
|
||||
);
|
||||
expect(() =>
|
||||
controller.review.requestChanges(deletedTask.id, { from: 'alice', comment: 'Fix it' })
|
||||
).toThrow('is deleted');
|
||||
|
|
@ -885,13 +934,19 @@ describe('agent-teams-controller API', () => {
|
|||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const pendingTask = controller.tasks.createTask({ subject: 'Pending implementation', owner: 'bob' });
|
||||
const pendingTask = controller.tasks.createTask({
|
||||
subject: 'Pending implementation',
|
||||
owner: 'bob',
|
||||
});
|
||||
expect(() => controller.review.startReview(pendingTask.id, { from: 'alice' })).toThrow(
|
||||
'must be completed before starting review'
|
||||
);
|
||||
expect(controller.tasks.getTask(pendingTask.id).reviewState).toBe('none');
|
||||
|
||||
const completedTask = controller.tasks.createTask({ subject: 'Completed without review request', owner: 'bob' });
|
||||
const completedTask = controller.tasks.createTask({
|
||||
subject: 'Completed without review request',
|
||||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
expect(() => controller.review.startReview(completedTask.id, { from: 'alice' })).toThrow(
|
||||
'must be in review before starting review'
|
||||
|
|
@ -907,12 +962,18 @@ describe('agent-teams-controller API', () => {
|
|||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
|
||||
const pendingTask = controller.tasks.createTask({ subject: 'Kanban bypass pending', owner: 'bob' });
|
||||
const pendingTask = controller.tasks.createTask({
|
||||
subject: 'Kanban bypass pending',
|
||||
owner: 'bob',
|
||||
});
|
||||
expect(() => controller.kanban.setKanbanColumn(pendingTask.id, 'approved')).toThrow(
|
||||
'must be completed before moving to APPROVED column'
|
||||
);
|
||||
|
||||
const completedTask = controller.tasks.createTask({ subject: 'Kanban bypass completed', owner: 'bob' });
|
||||
const completedTask = controller.tasks.createTask({
|
||||
subject: 'Kanban bypass completed',
|
||||
owner: 'bob',
|
||||
});
|
||||
controller.tasks.completeTask(completedTask.id, 'bob');
|
||||
expect(() => controller.kanban.setKanbanColumn(completedTask.id, 'review')).toThrow(
|
||||
'must be in review before moving to REVIEW column'
|
||||
|
|
@ -938,9 +999,9 @@ describe('agent-teams-controller API', () => {
|
|||
controller.review.startReview(task.id, { from: 'alice' });
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
|
||||
expect(() => controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })).toThrow(
|
||||
'is already approved'
|
||||
);
|
||||
expect(() =>
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' })
|
||||
).toThrow('is already approved');
|
||||
expect(controller.tasks.getTask(task.id).reviewState).toBe('approved');
|
||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
|
||||
});
|
||||
|
|
@ -963,7 +1024,9 @@ describe('agent-teams-controller API', () => {
|
|||
controller.review.startReview(task.id, { from: 'alice' });
|
||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('review');
|
||||
expect(
|
||||
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_started')
|
||||
controller.tasks
|
||||
.getTask(task.id)
|
||||
.historyEvents.filter((event) => event.type === 'review_started')
|
||||
).toHaveLength(1);
|
||||
|
||||
controller.review.approveReview(task.id, { from: 'alice' });
|
||||
|
|
@ -976,7 +1039,9 @@ describe('agent-teams-controller API', () => {
|
|||
expect(approvedAgain.alreadyApproved).toBe(true);
|
||||
expect(controller.kanban.getKanbanState().tasks[task.id].column).toBe('approved');
|
||||
expect(
|
||||
controller.tasks.getTask(task.id).historyEvents.filter((event) => event.type === 'review_approved')
|
||||
controller.tasks
|
||||
.getTask(task.id)
|
||||
.historyEvents.filter((event) => event.type === 'review_approved')
|
||||
).toHaveLength(1);
|
||||
});
|
||||
|
||||
|
|
@ -1189,7 +1254,11 @@ describe('agent-teams-controller API', () => {
|
|||
it('wakes task owner on regular comment from another member', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Investigate', owner: 'bob', notifyOwner: false });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Investigate',
|
||||
owner: 'bob',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
const commented = controller.tasks.addTaskComment(task.id, {
|
||||
from: 'alice',
|
||||
|
|
@ -1354,7 +1423,10 @@ describe('agent-teams-controller API', () => {
|
|||
it('rejects task comments from unknown authors', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Reject unknown author', notifyOwner: false });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Reject unknown author',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
expect(() =>
|
||||
controller.tasks.addTaskComment(task.id, {
|
||||
|
|
@ -1374,7 +1446,10 @@ describe('agent-teams-controller API', () => {
|
|||
claudeDir,
|
||||
allowUserMessageSender: false,
|
||||
});
|
||||
const task = appController.tasks.createTask({ subject: 'Reserved comment authors', notifyOwner: false });
|
||||
const task = appController.tasks.createTask({
|
||||
subject: 'Reserved comment authors',
|
||||
notifyOwner: false,
|
||||
});
|
||||
|
||||
const appComment = appController.tasks.addTaskComment(task.id, {
|
||||
from: 'user',
|
||||
|
|
@ -1803,11 +1878,19 @@ describe('agent-teams-controller API', () => {
|
|||
);
|
||||
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const leadOwnedTask = controller.tasks.createTask({ subject: 'Lead alias owner', owner: 'lead' });
|
||||
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);
|
||||
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' });
|
||||
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');
|
||||
|
|
@ -1822,8 +1905,12 @@ describe('agent-teams-controller API', () => {
|
|||
.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);
|
||||
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 () => {
|
||||
|
|
@ -1879,7 +1966,10 @@ describe('agent-teams-controller API', () => {
|
|||
it('clears kanban tasks and column order when task_set_status deletes a review task', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Generic status delete cleanup', owner: 'bob' });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Generic status delete cleanup',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'team-lead', reviewer: 'alice' });
|
||||
|
|
@ -1956,7 +2046,10 @@ describe('agent-teams-controller API', () => {
|
|||
it('guards direct kanban_clear against active review state while keeping no-op clears safe', () => {
|
||||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Do not unapprove directly', owner: 'bob' });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Do not unapprove directly',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
|
|
@ -1980,11 +2073,13 @@ describe('agent-teams-controller API', () => {
|
|||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Typo owner guard', owner: 'bob' });
|
||||
|
||||
expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow('Unknown task owner: boob');
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
expect(() => controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })).toThrow(
|
||||
'Unknown reviewer: boob'
|
||||
expect(() => controller.tasks.setTaskOwner(task.id, 'boob')).toThrow(
|
||||
'Unknown task owner: boob'
|
||||
);
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
expect(() =>
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'boob' })
|
||||
).toThrow('Unknown reviewer: boob');
|
||||
|
||||
const taskPath = path.join(claudeDir, 'tasks', 'my-team', `${task.id}.json`);
|
||||
const rawTask = JSON.parse(fs.readFileSync(taskPath, 'utf8'));
|
||||
|
|
@ -2006,8 +2101,12 @@ describe('agent-teams-controller API', () => {
|
|||
|
||||
controller.tasks.softDeleteTask(task.id, 'bob');
|
||||
|
||||
expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow('use task_restore before starting work');
|
||||
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow('use task_restore before changing status');
|
||||
expect(() => controller.tasks.startTask(task.id, 'bob')).toThrow(
|
||||
'use task_restore before starting work'
|
||||
);
|
||||
expect(() => controller.tasks.completeTask(task.id, 'bob')).toThrow(
|
||||
'use task_restore before changing status'
|
||||
);
|
||||
expect(() => controller.tasks.setTaskStatus(task.id, 'pending', 'bob')).toThrow(
|
||||
'use task_restore before changing status'
|
||||
);
|
||||
|
|
@ -2020,7 +2119,10 @@ describe('agent-teams-controller API', () => {
|
|||
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' });
|
||||
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' });
|
||||
|
|
@ -2047,7 +2149,9 @@ describe('agent-teams-controller API', () => {
|
|||
delete state.tasks[task.id];
|
||||
fs.writeFileSync(kanbanPath, JSON.stringify(state, null, 2));
|
||||
|
||||
expect(controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)).toContain(task.id);
|
||||
expect(
|
||||
controller.tasks.listTaskInventory({ reviewState: 'approved' }).map((row) => row.id)
|
||||
).toContain(task.id);
|
||||
expect(controller.tasks.listTaskInventory({ kanbanColumn: 'approved' })).toHaveLength(0);
|
||||
});
|
||||
|
||||
|
|
@ -2090,7 +2194,10 @@ describe('agent-teams-controller API', () => {
|
|||
config.members.push({ name: 'carol', role: 'reviewer' });
|
||||
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const task = controller.tasks.createTask({ subject: 'Repair mismatched reviewer actor', owner: 'bob' });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: 'Repair mismatched reviewer actor',
|
||||
owner: 'bob',
|
||||
});
|
||||
|
||||
controller.tasks.completeTask(task.id, 'bob');
|
||||
controller.review.requestReview(task.id, { from: 'alice', reviewer: 'alice' });
|
||||
|
|
@ -2124,7 +2231,11 @@ describe('agent-teams-controller API', () => {
|
|||
const claudeDir = makeClaudeDir();
|
||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||
const longSubject = `Long subject ${'x'.repeat(5000)}`;
|
||||
const task = controller.tasks.createTask({ subject: longSubject, owner: 'bob', notifyOwner: false });
|
||||
const task = controller.tasks.createTask({
|
||||
subject: longSubject,
|
||||
owner: 'bob',
|
||||
notifyOwner: false,
|
||||
});
|
||||
const kanbanPath = path.join(claudeDir, 'teams', 'my-team', 'kanban-state.json');
|
||||
fs.writeFileSync(
|
||||
kanbanPath,
|
||||
|
|
@ -2147,7 +2258,11 @@ describe('agent-teams-controller API', () => {
|
|||
'utf8'
|
||||
);
|
||||
for (let index = 0; index < 30; index += 1) {
|
||||
fs.writeFileSync(path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`), '{ bad json', 'utf8');
|
||||
fs.writeFileSync(
|
||||
path.join(claudeDir, 'tasks', 'my-team', `broken-${index}.json`),
|
||||
'{ bad json',
|
||||
'utf8'
|
||||
);
|
||||
}
|
||||
|
||||
const briefing = await controller.tasks.leadBriefing();
|
||||
|
|
|
|||
|
|
@ -0,0 +1,189 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" width="1400" height="1000" viewBox="0 0 1400 1000">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="48%" r="70%">
|
||||
<stop offset="0%" stop-color="#111827"/>
|
||||
<stop offset="58%" stop-color="#08091a"/>
|
||||
<stop offset="100%" stop-color="#050510"/>
|
||||
</radialGradient>
|
||||
<filter id="glow" x="-80%" y="-80%" width="260%" height="260%">
|
||||
<feGaussianBlur stdDeviation="10" result="blur"/>
|
||||
<feMerge><feMergeNode in="blur"/><feMergeNode in="SourceGraphic"/></feMerge>
|
||||
</filter>
|
||||
</defs>
|
||||
<style>
|
||||
.title{font:700 30px Inter,Arial,sans-serif;fill:#f8fafc;letter-spacing:0}
|
||||
.sub{font:500 18px Inter,Arial,sans-serif;fill:#94a3b8;letter-spacing:0}
|
||||
.edge{fill:none;stroke:#1d4ed8;stroke-width:2;opacity:.42}
|
||||
.card{fill:#090b1d;stroke:#26314f;stroke-width:1.5}
|
||||
.lead{fill:#142006;stroke:#9bef13;stroke-width:2;filter:url(#glow)}
|
||||
.label{font:700 18px Inter,Arial,sans-serif;fill:#f8fafc;text-anchor:middle;letter-spacing:0}
|
||||
.small{font:500 14px Inter,Arial,sans-serif;fill:#94a3b8;text-anchor:middle;letter-spacing:0}
|
||||
.badge{font:700 12px Inter,Arial,sans-serif;fill:#020617;text-anchor:middle;letter-spacing:0}
|
||||
</style>
|
||||
<rect width="100%" height="100%" fill="url(#bg)"/>
|
||||
<circle cx="0" cy="0" r="0.60" fill="#dbeafe" opacity="0.25"/>
|
||||
<circle cx="97" cy="211" r="1.54" fill="#dbeafe" opacity="0.56"/>
|
||||
<circle cx="194" cy="422" r="1.49" fill="#dbeafe" opacity="0.32"/>
|
||||
<circle cx="291" cy="633" r="1.43" fill="#dbeafe" opacity="0.63"/>
|
||||
<circle cx="388" cy="844" r="1.38" fill="#dbeafe" opacity="0.39"/>
|
||||
<circle cx="485" cy="55" r="1.32" fill="#dbeafe" opacity="0.70"/>
|
||||
<circle cx="582" cy="266" r="1.27" fill="#dbeafe" opacity="0.46"/>
|
||||
<circle cx="679" cy="477" r="1.21" fill="#dbeafe" opacity="0.77"/>
|
||||
<circle cx="776" cy="688" r="1.16" fill="#dbeafe" opacity="0.53"/>
|
||||
<circle cx="873" cy="899" r="1.10" fill="#dbeafe" opacity="0.29"/>
|
||||
<circle cx="970" cy="110" r="1.04" fill="#dbeafe" opacity="0.60"/>
|
||||
<circle cx="1067" cy="321" r="0.99" fill="#dbeafe" opacity="0.36"/>
|
||||
<circle cx="1164" cy="532" r="0.93" fill="#dbeafe" opacity="0.67"/>
|
||||
<circle cx="1261" cy="743" r="0.88" fill="#dbeafe" opacity="0.43"/>
|
||||
<circle cx="1358" cy="954" r="0.82" fill="#dbeafe" opacity="0.74"/>
|
||||
<circle cx="55" cy="165" r="0.77" fill="#dbeafe" opacity="0.50"/>
|
||||
<circle cx="152" cy="376" r="0.71" fill="#dbeafe" opacity="0.26"/>
|
||||
<circle cx="249" cy="587" r="0.66" fill="#dbeafe" opacity="0.57"/>
|
||||
<circle cx="346" cy="798" r="0.60" fill="#dbeafe" opacity="0.33"/>
|
||||
<circle cx="443" cy="9" r="1.54" fill="#dbeafe" opacity="0.64"/>
|
||||
<circle cx="540" cy="220" r="1.49" fill="#dbeafe" opacity="0.40"/>
|
||||
<circle cx="637" cy="431" r="1.43" fill="#dbeafe" opacity="0.71"/>
|
||||
<circle cx="734" cy="642" r="1.38" fill="#dbeafe" opacity="0.47"/>
|
||||
<circle cx="831" cy="853" r="1.32" fill="#dbeafe" opacity="0.78"/>
|
||||
<circle cx="928" cy="64" r="1.27" fill="#dbeafe" opacity="0.54"/>
|
||||
<circle cx="1025" cy="275" r="1.21" fill="#dbeafe" opacity="0.30"/>
|
||||
<circle cx="1122" cy="486" r="1.16" fill="#dbeafe" opacity="0.61"/>
|
||||
<circle cx="1219" cy="697" r="1.10" fill="#dbeafe" opacity="0.37"/>
|
||||
<circle cx="1316" cy="908" r="1.04" fill="#dbeafe" opacity="0.68"/>
|
||||
<circle cx="13" cy="119" r="0.99" fill="#dbeafe" opacity="0.44"/>
|
||||
<circle cx="110" cy="330" r="0.93" fill="#dbeafe" opacity="0.75"/>
|
||||
<circle cx="207" cy="541" r="0.88" fill="#dbeafe" opacity="0.51"/>
|
||||
<circle cx="304" cy="752" r="0.82" fill="#dbeafe" opacity="0.27"/>
|
||||
<circle cx="401" cy="963" r="0.77" fill="#dbeafe" opacity="0.58"/>
|
||||
<circle cx="498" cy="174" r="0.71" fill="#dbeafe" opacity="0.34"/>
|
||||
<circle cx="595" cy="385" r="0.66" fill="#dbeafe" opacity="0.65"/>
|
||||
<circle cx="692" cy="596" r="0.60" fill="#dbeafe" opacity="0.41"/>
|
||||
<circle cx="789" cy="807" r="1.54" fill="#dbeafe" opacity="0.72"/>
|
||||
<circle cx="886" cy="18" r="1.49" fill="#dbeafe" opacity="0.48"/>
|
||||
<circle cx="983" cy="229" r="1.43" fill="#dbeafe" opacity="0.79"/>
|
||||
<circle cx="1080" cy="440" r="1.38" fill="#dbeafe" opacity="0.55"/>
|
||||
<circle cx="1177" cy="651" r="1.32" fill="#dbeafe" opacity="0.31"/>
|
||||
<circle cx="1274" cy="862" r="1.27" fill="#dbeafe" opacity="0.62"/>
|
||||
<circle cx="1371" cy="73" r="1.21" fill="#dbeafe" opacity="0.38"/>
|
||||
<circle cx="68" cy="284" r="1.16" fill="#dbeafe" opacity="0.69"/>
|
||||
<circle cx="165" cy="495" r="1.10" fill="#dbeafe" opacity="0.45"/>
|
||||
<circle cx="262" cy="706" r="1.04" fill="#dbeafe" opacity="0.76"/>
|
||||
<circle cx="359" cy="917" r="0.99" fill="#dbeafe" opacity="0.52"/>
|
||||
<circle cx="456" cy="128" r="0.93" fill="#dbeafe" opacity="0.28"/>
|
||||
<circle cx="553" cy="339" r="0.88" fill="#dbeafe" opacity="0.59"/>
|
||||
<circle cx="650" cy="550" r="0.82" fill="#dbeafe" opacity="0.35"/>
|
||||
<circle cx="747" cy="761" r="0.77" fill="#dbeafe" opacity="0.66"/>
|
||||
<circle cx="844" cy="972" r="0.71" fill="#dbeafe" opacity="0.42"/>
|
||||
<circle cx="941" cy="183" r="0.66" fill="#dbeafe" opacity="0.73"/>
|
||||
<circle cx="1038" cy="394" r="0.60" fill="#dbeafe" opacity="0.49"/>
|
||||
<circle cx="1135" cy="605" r="1.54" fill="#dbeafe" opacity="0.25"/>
|
||||
<circle cx="1232" cy="816" r="1.49" fill="#dbeafe" opacity="0.56"/>
|
||||
<circle cx="1329" cy="27" r="1.43" fill="#dbeafe" opacity="0.32"/>
|
||||
<circle cx="26" cy="238" r="1.38" fill="#dbeafe" opacity="0.63"/>
|
||||
<circle cx="123" cy="449" r="1.32" fill="#dbeafe" opacity="0.39"/>
|
||||
<circle cx="220" cy="660" r="1.27" fill="#dbeafe" opacity="0.70"/>
|
||||
<circle cx="317" cy="871" r="1.21" fill="#dbeafe" opacity="0.46"/>
|
||||
<circle cx="414" cy="82" r="1.16" fill="#dbeafe" opacity="0.77"/>
|
||||
<circle cx="511" cy="293" r="1.10" fill="#dbeafe" opacity="0.53"/>
|
||||
<circle cx="608" cy="504" r="1.04" fill="#dbeafe" opacity="0.29"/>
|
||||
<circle cx="705" cy="715" r="0.99" fill="#dbeafe" opacity="0.60"/>
|
||||
<circle cx="802" cy="926" r="0.93" fill="#dbeafe" opacity="0.36"/>
|
||||
<circle cx="899" cy="137" r="0.88" fill="#dbeafe" opacity="0.67"/>
|
||||
<circle cx="996" cy="348" r="0.82" fill="#dbeafe" opacity="0.43"/>
|
||||
<circle cx="1093" cy="559" r="0.77" fill="#dbeafe" opacity="0.74"/>
|
||||
<circle cx="1190" cy="770" r="0.71" fill="#dbeafe" opacity="0.50"/>
|
||||
<circle cx="1287" cy="981" r="0.66" fill="#dbeafe" opacity="0.26"/>
|
||||
<circle cx="1384" cy="192" r="0.60" fill="#dbeafe" opacity="0.57"/>
|
||||
<circle cx="81" cy="403" r="1.54" fill="#dbeafe" opacity="0.33"/>
|
||||
<circle cx="178" cy="614" r="1.49" fill="#dbeafe" opacity="0.64"/>
|
||||
<circle cx="275" cy="825" r="1.43" fill="#dbeafe" opacity="0.40"/>
|
||||
<circle cx="372" cy="36" r="1.38" fill="#dbeafe" opacity="0.71"/>
|
||||
<circle cx="469" cy="247" r="1.32" fill="#dbeafe" opacity="0.47"/>
|
||||
<circle cx="566" cy="458" r="1.27" fill="#dbeafe" opacity="0.78"/>
|
||||
<circle cx="663" cy="669" r="1.21" fill="#dbeafe" opacity="0.54"/>
|
||||
<circle cx="760" cy="880" r="1.16" fill="#dbeafe" opacity="0.30"/>
|
||||
<circle cx="857" cy="91" r="1.10" fill="#dbeafe" opacity="0.61"/>
|
||||
<circle cx="954" cy="302" r="1.04" fill="#dbeafe" opacity="0.37"/>
|
||||
<circle cx="1051" cy="513" r="0.99" fill="#dbeafe" opacity="0.68"/>
|
||||
<circle cx="1148" cy="724" r="0.93" fill="#dbeafe" opacity="0.44"/>
|
||||
<circle cx="1245" cy="935" r="0.88" fill="#dbeafe" opacity="0.75"/>
|
||||
<circle cx="1342" cy="146" r="0.82" fill="#dbeafe" opacity="0.51"/>
|
||||
<circle cx="39" cy="357" r="0.77" fill="#dbeafe" opacity="0.27"/>
|
||||
<circle cx="136" cy="568" r="0.71" fill="#dbeafe" opacity="0.58"/>
|
||||
<circle cx="233" cy="779" r="0.66" fill="#dbeafe" opacity="0.34"/>
|
||||
<circle cx="330" cy="990" r="0.60" fill="#dbeafe" opacity="0.65"/>
|
||||
<circle cx="427" cy="201" r="1.54" fill="#dbeafe" opacity="0.41"/>
|
||||
<circle cx="524" cy="412" r="1.49" fill="#dbeafe" opacity="0.72"/>
|
||||
<circle cx="621" cy="623" r="1.43" fill="#dbeafe" opacity="0.48"/>
|
||||
<circle cx="718" cy="834" r="1.38" fill="#dbeafe" opacity="0.79"/>
|
||||
<circle cx="815" cy="45" r="1.32" fill="#dbeafe" opacity="0.55"/>
|
||||
<circle cx="912" cy="256" r="1.27" fill="#dbeafe" opacity="0.31"/>
|
||||
<circle cx="1009" cy="467" r="1.21" fill="#dbeafe" opacity="0.62"/>
|
||||
<circle cx="1106" cy="678" r="1.16" fill="#dbeafe" opacity="0.38"/>
|
||||
<circle cx="1203" cy="889" r="1.10" fill="#dbeafe" opacity="0.69"/>
|
||||
<circle cx="1300" cy="100" r="1.04" fill="#dbeafe" opacity="0.45"/>
|
||||
<circle cx="1397" cy="311" r="0.99" fill="#dbeafe" opacity="0.76"/>
|
||||
<circle cx="94" cy="522" r="0.93" fill="#dbeafe" opacity="0.52"/>
|
||||
<circle cx="191" cy="733" r="0.88" fill="#dbeafe" opacity="0.28"/>
|
||||
<circle cx="288" cy="944" r="0.82" fill="#dbeafe" opacity="0.59"/>
|
||||
<circle cx="385" cy="155" r="0.77" fill="#dbeafe" opacity="0.35"/>
|
||||
<circle cx="482" cy="366" r="0.71" fill="#dbeafe" opacity="0.66"/>
|
||||
<circle cx="579" cy="577" r="0.66" fill="#dbeafe" opacity="0.42"/>
|
||||
<circle cx="676" cy="788" r="0.60" fill="#dbeafe" opacity="0.73"/>
|
||||
<circle cx="773" cy="999" r="1.54" fill="#dbeafe" opacity="0.49"/>
|
||||
<circle cx="870" cy="210" r="1.49" fill="#dbeafe" opacity="0.25"/>
|
||||
<circle cx="967" cy="421" r="1.43" fill="#dbeafe" opacity="0.56"/>
|
||||
<circle cx="1064" cy="632" r="1.38" fill="#dbeafe" opacity="0.32"/>
|
||||
<circle cx="1161" cy="843" r="1.32" fill="#dbeafe" opacity="0.63"/>
|
||||
<circle cx="1258" cy="54" r="1.27" fill="#dbeafe" opacity="0.39"/>
|
||||
<circle cx="1355" cy="265" r="1.21" fill="#dbeafe" opacity="0.70"/>
|
||||
<circle cx="52" cy="476" r="1.16" fill="#dbeafe" opacity="0.46"/>
|
||||
<circle cx="149" cy="687" r="1.10" fill="#dbeafe" opacity="0.77"/>
|
||||
<circle cx="246" cy="898" r="1.04" fill="#dbeafe" opacity="0.53"/>
|
||||
<circle cx="343" cy="109" r="0.99" fill="#dbeafe" opacity="0.29"/>
|
||||
<text x="70" y="76" class="title">4 participants - current radial layout</text>
|
||||
<text x="70" y="110" class="sub">Strict small-team preset: top / right / bottom / left around Lead</text>
|
||||
<path d="M 700 500 C 700 500, 700 235, 700 235" class="edge"/>
|
||||
<path d="M 700 500 C 895 500, 895 500, 1090 500" class="edge"/>
|
||||
<path d="M 700 500 C 700 500, 700 765, 700 765" class="edge"/>
|
||||
<path d="M 700 500 C 505 500, 505 500, 310 500" class="edge"/>
|
||||
<rect x="615" y="457" width="170" height="86" rx="16" class="lead"/>
|
||||
<text x="700" y="496" class="label">Lead</text>
|
||||
<text x="700" y="524" class="small">center reserved zone</text>
|
||||
|
||||
<rect x="570" y="160" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="700" cy="187" r="20" fill="#38bdf8" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="700" cy="187" r="14" fill="#38bdf8"/>
|
||||
<text x="700" y="227" class="label">Participant 1</text>
|
||||
<text x="700" y="255" class="small">top side</text>
|
||||
<rect x="658" y="278" width="84" height="24" rx="6" fill="#38bdf8"/>
|
||||
<text x="700" y="295" class="badge">slot 1</text>
|
||||
|
||||
|
||||
<rect x="960" y="425" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="1090" cy="452" r="20" fill="#facc15" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="1090" cy="452" r="14" fill="#facc15"/>
|
||||
<text x="1090" y="492" class="label">Participant 2</text>
|
||||
<text x="1090" y="520" class="small">right side</text>
|
||||
<rect x="1048" y="543" width="84" height="24" rx="6" fill="#facc15"/>
|
||||
<text x="1090" y="560" class="badge">slot 2</text>
|
||||
|
||||
|
||||
<rect x="570" y="690" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="700" cy="717" r="20" fill="#ef4444" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="700" cy="717" r="14" fill="#ef4444"/>
|
||||
<text x="700" y="757" class="label">Participant 3</text>
|
||||
<text x="700" y="785" class="small">bottom side</text>
|
||||
<rect x="658" y="808" width="84" height="24" rx="6" fill="#ef4444"/>
|
||||
<text x="700" y="825" class="badge">slot 3</text>
|
||||
|
||||
|
||||
<rect x="180" y="425" width="260" height="150" rx="10" class="card"/>
|
||||
<circle cx="310" cy="452" r="20" fill="#a78bfa" opacity=".18" filter="url(#glow)"/>
|
||||
<circle cx="310" cy="452" r="14" fill="#a78bfa"/>
|
||||
<text x="310" y="492" class="label">Participant 4</text>
|
||||
<text x="310" y="520" class="small">left side</text>
|
||||
<rect x="268" y="543" width="84" height="24" rx="6" fill="#a78bfa"/>
|
||||
<text x="310" y="560" class="badge">slot 4</text>
|
||||
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 12 KiB |
125
docs/screenshots/agent-graph-row-orbit-layout-preview.svg
Normal file
125
docs/screenshots/agent-graph-row-orbit-layout-preview.svg
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
<svg width="1800" height="1050" viewBox="0 0 1800 1050" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<defs>
|
||||
<radialGradient id="bg" cx="50%" cy="42%" r="75%">
|
||||
<stop offset="0%" stop-color="#111733"/>
|
||||
<stop offset="55%" stop-color="#090d20"/>
|
||||
<stop offset="100%" stop-color="#050717"/>
|
||||
</radialGradient>
|
||||
<filter id="glow" x="-80%" y="-80%" width="260%" height="260%">
|
||||
<feGaussianBlur stdDeviation="10" result="blur"/>
|
||||
<feColorMatrix in="blur" type="matrix" values="0 0 0 0 0.25 0 0 0 0 0.75 0 0 0 0 1 0 0 0 0.65 0"/>
|
||||
<feBlend in="SourceGraphic"/>
|
||||
</filter>
|
||||
<style>
|
||||
.panel-title { font: 700 28px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #e6f0ff; letter-spacing: 0; }
|
||||
.panel-subtitle { font: 500 15px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8fa4c6; letter-spacing: 0; }
|
||||
.label { font: 700 13px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #eff6ff; letter-spacing: 0; }
|
||||
.role { font: 500 10px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8ea0bd; letter-spacing: 0; }
|
||||
.hint { font: 600 12px ui-monospace, SFMono-Regular, Menlo, monospace; fill: #8fa4c6; letter-spacing: 0; }
|
||||
.card { fill: rgba(11, 16, 36, 0.84); stroke: rgba(148, 163, 184, 0.22); stroke-width: 1; }
|
||||
.slot { fill: rgba(59, 130, 246, 0.035); stroke: rgba(125, 211, 252, 0.16); stroke-width: 1; stroke-dasharray: 5 7; }
|
||||
.edge { stroke: rgba(96, 165, 250, 0.18); stroke-width: 2; }
|
||||
.row-guide { stroke: rgba(148, 163, 184, 0.14); stroke-width: 1; stroke-dasharray: 6 10; }
|
||||
.divider { stroke: rgba(148, 163, 184, 0.16); stroke-width: 1; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<rect width="1800" height="1050" fill="url(#bg)"/>
|
||||
<g opacity="0.85">
|
||||
<circle cx="108" cy="102" r="1.5" fill="#94a3b8"/>
|
||||
<circle cx="238" cy="860" r="1.2" fill="#64748b"/>
|
||||
<circle cx="385" cy="145" r="1.3" fill="#94a3b8"/>
|
||||
<circle cx="520" cy="950" r="1.4" fill="#cbd5e1"/>
|
||||
<circle cx="690" cy="372" r="1.1" fill="#94a3b8"/>
|
||||
<circle cx="823" cy="787" r="1.2" fill="#64748b"/>
|
||||
<circle cx="982" cy="210" r="1.1" fill="#94a3b8"/>
|
||||
<circle cx="1112" cy="902" r="1.5" fill="#cbd5e1"/>
|
||||
<circle cx="1286" cy="118" r="1.2" fill="#94a3b8"/>
|
||||
<circle cx="1458" cy="730" r="1.3" fill="#64748b"/>
|
||||
<circle cx="1632" cy="340" r="1.5" fill="#cbd5e1"/>
|
||||
<circle cx="1748" cy="934" r="1.1" fill="#94a3b8"/>
|
||||
</g>
|
||||
|
||||
<line x1="900" y1="70" x2="900" y2="980" class="divider"/>
|
||||
|
||||
<text x="70" y="72" class="panel-title">8 participants</text>
|
||||
<text x="70" y="101" class="panel-subtitle">3 top / 2 at lead level / 3 bottom</text>
|
||||
<text x="970" y="72" class="panel-title">12 participants</text>
|
||||
<text x="970" y="101" class="panel-subtitle">4 top / 2 + lead + 2 middle / 4 bottom</text>
|
||||
|
||||
<g id="eight-layout">
|
||||
<line x1="110" y1="245" x2="790" y2="245" class="row-guide"/>
|
||||
<line x1="110" y1="525" x2="790" y2="525" class="row-guide"/>
|
||||
<line x1="110" y1="805" x2="790" y2="805" class="row-guide"/>
|
||||
<text x="118" y="232" class="hint">top row</text>
|
||||
<text x="118" y="512" class="hint">lead row</text>
|
||||
<text x="118" y="792" class="hint">bottom row</text>
|
||||
|
||||
<path d="M450 525 C370 430 305 330 245 245" class="edge"/>
|
||||
<path d="M450 525 C450 425 450 335 450 245" class="edge"/>
|
||||
<path d="M450 525 C530 430 595 330 655 245" class="edge"/>
|
||||
<path d="M450 525 C360 515 285 515 200 525" class="edge"/>
|
||||
<path d="M450 525 C540 515 615 515 700 525" class="edge"/>
|
||||
<path d="M450 525 C370 620 305 720 245 805" class="edge"/>
|
||||
<path d="M450 525 C450 625 450 715 450 805" class="edge"/>
|
||||
<path d="M450 525 C530 620 595 720 655 805" class="edge"/>
|
||||
|
||||
<g transform="translate(450 525)">
|
||||
<circle r="56" fill="rgba(132, 204, 22, 0.11)" filter="url(#glow)"/>
|
||||
<path d="M0 -35 L31 -17.5 L31 17.5 L0 35 L-31 17.5 L-31 -17.5 Z" fill="#1a2f0d" stroke="#a3e635" stroke-width="2"/>
|
||||
<circle r="17" fill="#84cc16"/>
|
||||
<text x="0" y="66" text-anchor="middle" class="label">lead</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(245 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#0ea5e9"/><text y="17" text-anchor="middle" class="label">alice</text><text y="34" text-anchor="middle" class="role">reviewer</text></g>
|
||||
<g transform="translate(450 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#22c55e"/><text y="17" text-anchor="middle" class="label">nova</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(655 245)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#eab308"/><text y="17" text-anchor="middle" class="label">tom</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(200 525)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#8b5cf6"/><text y="17" text-anchor="middle" class="label">jack</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(700 525)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#06b6d4"/><text y="17" text-anchor="middle" class="label">atlas</text><text y="34" text-anchor="middle" class="role">assistant</text></g>
|
||||
<g transform="translate(245 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#ef4444"/><text y="17" text-anchor="middle" class="label">bob</text><text y="34" text-anchor="middle" class="role">developer</text></g>
|
||||
<g transform="translate(450 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#f97316"/><text y="17" text-anchor="middle" class="label">maya</text><text y="34" text-anchor="middle" class="role">qa</text></g>
|
||||
<g transform="translate(655 805)"><rect x="-92" y="-64" width="184" height="128" rx="8" class="slot"/><rect x="-78" y="-46" width="156" height="92" rx="7" class="card"/><circle cy="-18" r="20" fill="#14b8a6"/><text y="17" text-anchor="middle" class="label">kai</text><text y="34" text-anchor="middle" class="role">ops</text></g>
|
||||
</g>
|
||||
|
||||
<g id="twelve-layout">
|
||||
<line x1="970" y1="245" x2="1730" y2="245" class="row-guide"/>
|
||||
<line x1="970" y1="525" x2="1730" y2="525" class="row-guide"/>
|
||||
<line x1="970" y1="805" x2="1730" y2="805" class="row-guide"/>
|
||||
<text x="978" y="232" class="hint">top row</text>
|
||||
<text x="978" y="512" class="hint">lead row</text>
|
||||
<text x="978" y="792" class="hint">bottom row</text>
|
||||
|
||||
<path d="M1350 525 C1245 425 1135 330 1030 245" class="edge"/>
|
||||
<path d="M1350 525 C1295 420 1270 335 1243 245" class="edge"/>
|
||||
<path d="M1350 525 C1405 420 1430 335 1457 245" class="edge"/>
|
||||
<path d="M1350 525 C1455 425 1565 330 1670 245" class="edge"/>
|
||||
<path d="M1350 525 C1235 515 1135 515 1030 525" class="edge"/>
|
||||
<path d="M1350 525 C1270 520 1235 520 1210 525" class="edge"/>
|
||||
<path d="M1350 525 C1430 520 1465 520 1490 525" class="edge"/>
|
||||
<path d="M1350 525 C1465 515 1565 515 1670 525" class="edge"/>
|
||||
<path d="M1350 525 C1245 625 1135 720 1030 805" class="edge"/>
|
||||
<path d="M1350 525 C1295 630 1270 715 1243 805" class="edge"/>
|
||||
<path d="M1350 525 C1405 630 1430 715 1457 805" class="edge"/>
|
||||
<path d="M1350 525 C1455 625 1565 720 1670 805" class="edge"/>
|
||||
|
||||
<g transform="translate(1350 525)">
|
||||
<circle r="56" fill="rgba(132, 204, 22, 0.11)" filter="url(#glow)"/>
|
||||
<path d="M0 -35 L31 -17.5 L31 17.5 L0 35 L-31 17.5 L-31 -17.5 Z" fill="#1a2f0d" stroke="#a3e635" stroke-width="2"/>
|
||||
<circle r="17" fill="#84cc16"/>
|
||||
<text x="0" y="66" text-anchor="middle" class="label">lead</text>
|
||||
</g>
|
||||
|
||||
<g transform="translate(1030 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#0ea5e9"/><text y="17" text-anchor="middle" class="label">alice</text></g>
|
||||
<g transform="translate(1243 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#22c55e"/><text y="17" text-anchor="middle" class="label">nova</text></g>
|
||||
<g transform="translate(1457 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#eab308"/><text y="17" text-anchor="middle" class="label">tom</text></g>
|
||||
<g transform="translate(1670 245)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#8b5cf6"/><text y="17" text-anchor="middle" class="label">jack</text></g>
|
||||
<g transform="translate(1030 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#06b6d4"/><text y="17" text-anchor="middle" class="label">atlas</text></g>
|
||||
<g transform="translate(1210 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#ef4444"/><text y="17" text-anchor="middle" class="label">bob</text></g>
|
||||
<g transform="translate(1490 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#f97316"/><text y="17" text-anchor="middle" class="label">maya</text></g>
|
||||
<g transform="translate(1670 525)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#14b8a6"/><text y="17" text-anchor="middle" class="label">kai</text></g>
|
||||
<g transform="translate(1030 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#a855f7"/><text y="17" text-anchor="middle" class="label">ivy</text></g>
|
||||
<g transform="translate(1243 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#f43f5e"/><text y="17" text-anchor="middle" class="label">rex</text></g>
|
||||
<g transform="translate(1457 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#38bdf8"/><text y="17" text-anchor="middle" class="label">zoe</text></g>
|
||||
<g transform="translate(1670 805)"><rect x="-80" y="-62" width="160" height="124" rx="8" class="slot"/><rect x="-68" y="-44" width="136" height="88" rx="7" class="card"/><circle cy="-18" r="18" fill="#84cc16"/><text y="17" text-anchor="middle" class="label">sam</text></g>
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 11 KiB |
|
|
@ -132,6 +132,7 @@ export default defineConfig({
|
|||
}
|
||||
},
|
||||
renderer: {
|
||||
cacheDir: resolve(__dirname, 'node_modules/.vite/electron-renderer'),
|
||||
optimizeDeps: {
|
||||
include: ['@codemirror/language-data'],
|
||||
exclude: ['@claude-teams/agent-graph']
|
||||
|
|
|
|||
|
|
@ -50,6 +50,8 @@ export interface SlotFrame {
|
|||
taskColumnCount: number;
|
||||
}
|
||||
|
||||
type OwnerSlotLayoutKind = 'radial-sector' | 'row-orbit' | 'grid-under-lead';
|
||||
|
||||
export interface StableSlotLayoutSnapshot {
|
||||
version: GraphLayoutPort['version'];
|
||||
teamName: string;
|
||||
|
|
@ -61,6 +63,7 @@ export interface StableSlotLayoutSnapshot {
|
|||
launchAnchor: { x: number; y: number } | null;
|
||||
leadCentralReservedBlock: StableRect;
|
||||
runtimeCentralExclusion: StableRect;
|
||||
ownerSlotLayoutKind: OwnerSlotLayoutKind;
|
||||
centralCollisionRects: StableRect[];
|
||||
memberSlotFrames: SlotFrame[];
|
||||
memberSlotFrameByOwnerId: Map<string, SlotFrame>;
|
||||
|
|
@ -104,6 +107,20 @@ interface RingLayoutState {
|
|||
|
||||
type RingLayoutStateMap = ReadonlyMap<string, RingLayoutState>;
|
||||
|
||||
interface PlannedMemberSlotLayout {
|
||||
frames: SlotFrame[];
|
||||
kind: OwnerSlotLayoutKind;
|
||||
}
|
||||
|
||||
interface RowOrbitSlotConfig {
|
||||
footprint: OwnerFootprint;
|
||||
assignment: GraphOwnerSlotAssignment;
|
||||
rowIndex: number;
|
||||
columnIndex: number;
|
||||
columnCount: number;
|
||||
band: 'top' | 'middle' | 'bottom';
|
||||
}
|
||||
|
||||
const SLOT_GEOMETRY = {
|
||||
...STABLE_SLOT_GEOMETRY,
|
||||
activityColumnHeight:
|
||||
|
|
@ -129,11 +146,19 @@ const PROCESS_RAIL_NODE_GAP = 42;
|
|||
const PROCESS_RAIL_NODE_FOOTPRINT = 28;
|
||||
const GEOMETRY_EPSILON = 0.001;
|
||||
const FEED_HEADER_BOTTOM_GAP = 4;
|
||||
const SMALL_TEAM_CARDINAL_RADIUS_STEP = 24;
|
||||
const SMALL_TEAM_CARDINAL_VERTICAL_PADDING = 77.7;
|
||||
const GRID_UNDER_LEAD_COLUMN_COUNT = 2;
|
||||
const STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS = 96;
|
||||
const STRICT_SMALL_TEAM_RADIUS_EPSILON = 0.5;
|
||||
const STRICT_SMALL_TEAM_RADIUS_STEP = 24;
|
||||
const GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT = 2;
|
||||
const GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT = 3;
|
||||
const GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT = 6;
|
||||
const GRID_UNDER_LEAD_LEAD_GAP = 77.7;
|
||||
const GRID_UNDER_LEAD_ROW_GAP = 77.7;
|
||||
const ROW_ORBIT_MIN_OWNER_COUNT = 6;
|
||||
const ROW_ORBIT_MAX_OWNER_COUNT = 12;
|
||||
const ROW_ORBIT_HORIZONTAL_GAP = Math.max(112, STABLE_SLOT_GEOMETRY.slotHorizontalGap);
|
||||
const ROW_ORBIT_VERTICAL_GAP = Math.max(144, GRID_UNDER_LEAD_ROW_GAP);
|
||||
const ROW_ORBIT_CENTRAL_GAP = 160;
|
||||
|
||||
const SECTOR_VECTORS = STABLE_SLOT_SECTOR_VECTORS;
|
||||
const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
|
||||
|
|
@ -159,14 +184,48 @@ const SMALL_TEAM_CARDINAL_LAYOUTS: ReadonlyArray<
|
|||
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: { x: 0, y: 1 } },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 3 }, vector: { x: -1, y: 0 } },
|
||||
],
|
||||
[
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: SECTOR_VECTORS[0] },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 1 }, vector: SECTOR_VECTORS[1] },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: SECTOR_VECTORS[2] },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 4 }, vector: SECTOR_VECTORS[4] },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 5 }, vector: SECTOR_VECTORS[5] },
|
||||
],
|
||||
[
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 0 }, vector: SECTOR_VECTORS[0] },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 1 }, vector: SECTOR_VECTORS[1] },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 2 }, vector: SECTOR_VECTORS[2] },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 3 }, vector: SECTOR_VECTORS[3] },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 4 }, vector: SECTOR_VECTORS[4] },
|
||||
{ assignment: { ringIndex: 0, sectorIndex: 5 }, vector: SECTOR_VECTORS[5] },
|
||||
],
|
||||
];
|
||||
|
||||
const SMALL_TEAM_CARDINAL_ASSIGNMENTS: ReadonlyArray<ReadonlyArray<GraphOwnerSlotAssignment>> =
|
||||
SMALL_TEAM_CARDINAL_LAYOUTS.map((layout) => layout.map((slot) => slot.assignment));
|
||||
const SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY = new Map(
|
||||
SMALL_TEAM_CARDINAL_LAYOUTS.flatMap((layout) =>
|
||||
layout.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const)
|
||||
)
|
||||
|
||||
const ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT: Readonly<Record<number, readonly number[]>> = {
|
||||
6: [3, 2, 3],
|
||||
7: [3, 2, 2],
|
||||
8: [3, 2, 3],
|
||||
9: [3, 2, 2, 2],
|
||||
10: [3, 2, 2, 3],
|
||||
11: [3, 3, 2, 3],
|
||||
12: [3, 3, 3, 3],
|
||||
};
|
||||
|
||||
const ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT: Readonly<
|
||||
Record<number, readonly GraphOwnerSlotAssignment[]>
|
||||
> = Object.fromEntries(
|
||||
Object.entries(ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT).map(([ownerCount, rowCounts]) => [
|
||||
Number(ownerCount),
|
||||
rowCounts.flatMap((columnCount, rowIndex) =>
|
||||
Array.from({ length: columnCount }, (_, columnIndex) => ({
|
||||
ringIndex: rowIndex,
|
||||
sectorIndex: columnIndex,
|
||||
}))
|
||||
),
|
||||
])
|
||||
);
|
||||
|
||||
export function buildStableSlotLayoutSnapshot({
|
||||
|
|
@ -201,10 +260,14 @@ export function buildStableSlotLayoutSnapshot({
|
|||
SLOT_GEOMETRY.centralPadding
|
||||
);
|
||||
|
||||
const memberSlotFrames =
|
||||
const memberSlotLayout =
|
||||
(layout?.mode ?? 'radial') === 'grid-under-lead'
|
||||
? planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects)
|
||||
? {
|
||||
frames: planGridUnderLeadOwnerSlots(ownerFootprints, centralCollisionRects),
|
||||
kind: 'grid-under-lead' as const,
|
||||
}
|
||||
: planOwnerSlots(ownerFootprints, centralCollisionRects, runtimeCentralExclusion, layout);
|
||||
const memberSlotFrames = memberSlotLayout.frames;
|
||||
const memberSlotFrameByOwnerId = new Map(
|
||||
memberSlotFrames.map((frame) => [frame.ownerId, frame] as const)
|
||||
);
|
||||
|
|
@ -223,6 +286,7 @@ export function buildStableSlotLayoutSnapshot({
|
|||
launchAnchor: null,
|
||||
leadCentralReservedBlock,
|
||||
runtimeCentralExclusion,
|
||||
ownerSlotLayoutKind: memberSlotLayout.kind,
|
||||
centralCollisionRects,
|
||||
memberSlotFrames,
|
||||
memberSlotFrameByOwnerId,
|
||||
|
|
@ -457,6 +521,21 @@ export function resolveNearestSlotAssignment(args: {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (args.snapshot.ownerSlotLayoutKind === 'row-orbit') {
|
||||
const rowOrbitCandidate = resolveNearestRowOrbitSlotAssignment({
|
||||
ownerId: args.ownerId,
|
||||
ownerX: args.ownerX,
|
||||
ownerY: args.ownerY,
|
||||
currentFrame,
|
||||
ownerFootprints: allFootprints,
|
||||
snapshot: args.snapshot,
|
||||
layout: args.layout,
|
||||
});
|
||||
if (rowOrbitCandidate) {
|
||||
return rowOrbitCandidate;
|
||||
}
|
||||
}
|
||||
|
||||
const strictSmallTeamCandidate = resolveStrictSmallTeamNearestSlotAssignment({
|
||||
ownerId: args.ownerId,
|
||||
ownerX: args.ownerX,
|
||||
|
|
@ -568,11 +647,119 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
|
|||
return null;
|
||||
}
|
||||
|
||||
return resolveNearestExistingFrameSlotAssignment({
|
||||
ownerId: args.ownerId,
|
||||
ownerX: args.ownerX,
|
||||
ownerY: args.ownerY,
|
||||
currentFrame: args.currentFrame,
|
||||
frames: strictFrames,
|
||||
});
|
||||
}
|
||||
|
||||
function resolveNearestRowOrbitSlotAssignment(args: {
|
||||
ownerId: string;
|
||||
ownerX: number;
|
||||
ownerY: number;
|
||||
currentFrame: SlotFrame;
|
||||
ownerFootprints: readonly OwnerFootprint[];
|
||||
snapshot: StableSlotLayoutSnapshot;
|
||||
layout?: GraphLayoutPort;
|
||||
}): NearestSlotAssignmentResult | null {
|
||||
const allowedAssignments = ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT[args.ownerFootprints.length];
|
||||
if (!allowedAssignments || allowedAssignments.length < args.ownerFootprints.length) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const baseAssignments = Object.fromEntries(
|
||||
args.snapshot.memberSlotFrames.map((frame) => [
|
||||
frame.ownerId,
|
||||
{
|
||||
ringIndex: frame.ringIndex,
|
||||
sectorIndex: frame.sectorIndex,
|
||||
},
|
||||
])
|
||||
);
|
||||
let best: RankedNearestSlotAssignmentResult | null = null;
|
||||
|
||||
for (const assignment of allowedAssignments) {
|
||||
const occupiedFrame = args.snapshot.memberSlotFrames.find(
|
||||
(frame) =>
|
||||
frame.ownerId !== args.ownerId &&
|
||||
frame.ringIndex === assignment.ringIndex &&
|
||||
frame.sectorIndex === assignment.sectorIndex
|
||||
);
|
||||
const simulatedAssignments: Record<string, GraphOwnerSlotAssignment> = {
|
||||
...baseAssignments,
|
||||
[args.ownerId]: assignment,
|
||||
};
|
||||
if (occupiedFrame) {
|
||||
simulatedAssignments[occupiedFrame.ownerId] = {
|
||||
ringIndex: args.currentFrame.ringIndex,
|
||||
sectorIndex: args.currentFrame.sectorIndex,
|
||||
};
|
||||
}
|
||||
|
||||
const frames = planRowOrbitOwnerSlots(
|
||||
args.ownerFootprints,
|
||||
args.snapshot.centralCollisionRects,
|
||||
args.snapshot.runtimeCentralExclusion,
|
||||
{
|
||||
version: args.layout?.version ?? 'stable-slots-v1',
|
||||
mode: args.layout?.mode ?? 'radial',
|
||||
ownerOrder:
|
||||
args.layout?.ownerOrder ?? args.ownerFootprints.map((footprint) => footprint.ownerId),
|
||||
slotAssignments: simulatedAssignments,
|
||||
}
|
||||
);
|
||||
const previewFrame = frames?.find((frame) => frame.ownerId === args.ownerId);
|
||||
if (!previewFrame) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const dx = previewFrame.ownerX - args.ownerX;
|
||||
const dy = previewFrame.ownerY - args.ownerY;
|
||||
const candidate: RankedNearestSlotAssignmentResult = {
|
||||
assignment,
|
||||
displacedOwnerId: occupiedFrame?.ownerId,
|
||||
displacedAssignment: occupiedFrame
|
||||
? {
|
||||
ringIndex: args.currentFrame.ringIndex,
|
||||
sectorIndex: args.currentFrame.sectorIndex,
|
||||
}
|
||||
: undefined,
|
||||
previewOwnerX: previewFrame.ownerX,
|
||||
previewOwnerY: previewFrame.ownerY,
|
||||
distanceSquared: dx * dx + dy * dy,
|
||||
};
|
||||
|
||||
if (!best || candidate.distanceSquared < best.distanceSquared) {
|
||||
best = candidate;
|
||||
}
|
||||
}
|
||||
|
||||
return best
|
||||
? {
|
||||
assignment: best.assignment,
|
||||
displacedOwnerId: best.displacedOwnerId,
|
||||
displacedAssignment: best.displacedAssignment,
|
||||
previewOwnerX: best.previewOwnerX,
|
||||
previewOwnerY: best.previewOwnerY,
|
||||
}
|
||||
: null;
|
||||
}
|
||||
|
||||
function resolveNearestExistingFrameSlotAssignment(args: {
|
||||
ownerId: string;
|
||||
ownerX: number;
|
||||
ownerY: number;
|
||||
currentFrame: SlotFrame;
|
||||
frames: readonly SlotFrame[];
|
||||
}): NearestSlotAssignmentResult | null {
|
||||
let best: {
|
||||
frame: SlotFrame;
|
||||
distanceSquared: number;
|
||||
} | null = null;
|
||||
for (const frame of strictFrames) {
|
||||
for (const frame of args.frames) {
|
||||
const dx = frame.ownerX - args.ownerX;
|
||||
const dy = frame.ownerY - args.ownerY;
|
||||
const distanceSquared = dx * dx + dy * dy;
|
||||
|
|
@ -613,7 +800,7 @@ function resolveStrictSmallTeamNearestSlotAssignment(args: {
|
|||
}
|
||||
|
||||
function getStrictSmallTeamFrames(frames: readonly SlotFrame[]): readonly SlotFrame[] | null {
|
||||
if (frames.length === 0 || frames.length > 4) {
|
||||
if (frames.length === 0 || frames.length > 6) {
|
||||
return null;
|
||||
}
|
||||
const preset = SMALL_TEAM_CARDINAL_ASSIGNMENTS[frames.length];
|
||||
|
|
@ -968,7 +1155,22 @@ function planOwnerSlots(
|
|||
centralCollisionRects: readonly StableRect[],
|
||||
runtimeCentralExclusion: StableRect,
|
||||
layout?: GraphLayoutPort
|
||||
): SlotFrame[] {
|
||||
): PlannedMemberSlotLayout {
|
||||
const rowOrbitFrames = shouldUseRowOrbitLayout(ownerFootprints, layout)
|
||||
? planRowOrbitOwnerSlots(
|
||||
ownerFootprints,
|
||||
centralCollisionRects,
|
||||
runtimeCentralExclusion,
|
||||
layout
|
||||
)
|
||||
: null;
|
||||
if (rowOrbitFrames) {
|
||||
return {
|
||||
frames: rowOrbitFrames,
|
||||
kind: 'row-orbit',
|
||||
};
|
||||
}
|
||||
|
||||
const strictSmallTeamFrames = shouldUseStrictSmallTeamCardinalLayout(ownerFootprints, layout)
|
||||
? planStrictSmallTeamOwnerSlots(
|
||||
ownerFootprints,
|
||||
|
|
@ -978,7 +1180,10 @@ function planOwnerSlots(
|
|||
)
|
||||
: null;
|
||||
if (strictSmallTeamFrames) {
|
||||
return strictSmallTeamFrames;
|
||||
return {
|
||||
frames: strictSmallTeamFrames,
|
||||
kind: 'radial-sector',
|
||||
};
|
||||
}
|
||||
|
||||
const placedFrames: SlotFrame[] = [];
|
||||
|
|
@ -1002,7 +1207,354 @@ function planOwnerSlots(
|
|||
commitRingPlacement(ringStates, resolvedFrame, footprint);
|
||||
}
|
||||
|
||||
return placedFrames;
|
||||
return {
|
||||
frames: placedFrames,
|
||||
kind: 'radial-sector',
|
||||
};
|
||||
}
|
||||
|
||||
function shouldUseRowOrbitLayout(
|
||||
ownerFootprints: readonly OwnerFootprint[],
|
||||
layout?: GraphLayoutPort
|
||||
): boolean {
|
||||
if (
|
||||
ownerFootprints.length < ROW_ORBIT_MIN_OWNER_COUNT ||
|
||||
ownerFootprints.length > ROW_ORBIT_MAX_OWNER_COUNT
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const preset = ROW_ORBIT_ASSIGNMENTS_BY_OWNER_COUNT[ownerFootprints.length];
|
||||
if (!preset || preset.length < ownerFootprints.length) {
|
||||
return false;
|
||||
}
|
||||
const rowCounts = ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT[ownerFootprints.length];
|
||||
if (!rowCounts) {
|
||||
return false;
|
||||
}
|
||||
const actualAssignments = ownerFootprints
|
||||
.map((footprint) => layout?.slotAssignments?.[footprint.ownerId])
|
||||
.filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null);
|
||||
const useLegacySixTwoRowAssignments = shouldNormalizeLegacySixTwoRowAssignments(
|
||||
ownerFootprints.length,
|
||||
actualAssignments
|
||||
);
|
||||
|
||||
const actualAssignmentKeys = actualAssignments
|
||||
.map((assignment) =>
|
||||
normalizeRowOrbitAssignment(assignment, ownerFootprints.length, rowCounts, {
|
||||
useLegacySixTwoRowAssignments,
|
||||
})
|
||||
)
|
||||
.filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null)
|
||||
.map((assignment) => buildAssignmentKey(assignment))
|
||||
.sort();
|
||||
const allowedAssignmentKeys = new Set(preset.map((assignment) => buildAssignmentKey(assignment)));
|
||||
|
||||
if (actualAssignmentKeys.length !== ownerFootprints.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const uniqueAssignmentKeys = new Set(actualAssignmentKeys);
|
||||
if (uniqueAssignmentKeys.size !== actualAssignmentKeys.length) {
|
||||
return false;
|
||||
}
|
||||
|
||||
for (const assignmentKey of actualAssignmentKeys) {
|
||||
if (!allowedAssignmentKeys.has(assignmentKey)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function planRowOrbitOwnerSlots(
|
||||
ownerFootprints: readonly OwnerFootprint[],
|
||||
centralCollisionRects: readonly StableRect[],
|
||||
runtimeCentralExclusion: StableRect,
|
||||
layout?: GraphLayoutPort
|
||||
): SlotFrame[] | null {
|
||||
const rowCounts = ROW_ORBIT_ROW_COUNTS_BY_OWNER_COUNT[ownerFootprints.length];
|
||||
if (!rowCounts) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const slotConfigs = buildRowOrbitSlotConfigs(ownerFootprints, rowCounts, layout);
|
||||
if (!slotConfigs) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const frames = buildRowOrbitSlotFrames(slotConfigs, rowCounts, runtimeCentralExclusion);
|
||||
const allValid = frames.every((frame, frameIndex) =>
|
||||
isSlotFramePlacementValid(
|
||||
frame,
|
||||
frames.filter((_, index) => index !== frameIndex),
|
||||
centralCollisionRects
|
||||
)
|
||||
);
|
||||
return allValid ? frames : null;
|
||||
}
|
||||
|
||||
function buildRowOrbitSlotConfigs(
|
||||
ownerFootprints: readonly OwnerFootprint[],
|
||||
rowCounts: readonly number[],
|
||||
layout?: GraphLayoutPort
|
||||
): RowOrbitSlotConfig[] | null {
|
||||
const rowCount = rowCounts.length;
|
||||
const middleRowIndex = rowCount === 3 ? 1 : -1;
|
||||
const configs: RowOrbitSlotConfig[] = [];
|
||||
const actualAssignments = ownerFootprints
|
||||
.map((footprint) => layout?.slotAssignments?.[footprint.ownerId])
|
||||
.filter((assignment): assignment is GraphOwnerSlotAssignment => assignment != null);
|
||||
const useLegacySixTwoRowAssignments = shouldNormalizeLegacySixTwoRowAssignments(
|
||||
ownerFootprints.length,
|
||||
actualAssignments
|
||||
);
|
||||
|
||||
for (const footprint of ownerFootprints) {
|
||||
const assignment = layout?.slotAssignments?.[footprint.ownerId];
|
||||
if (!assignment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const rowOrbitAssignment = normalizeRowOrbitAssignment(
|
||||
assignment,
|
||||
ownerFootprints.length,
|
||||
rowCounts,
|
||||
{
|
||||
useLegacySixTwoRowAssignments,
|
||||
}
|
||||
);
|
||||
if (!rowOrbitAssignment) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const columnCount = rowCounts[rowOrbitAssignment.ringIndex];
|
||||
if (
|
||||
columnCount == null ||
|
||||
rowOrbitAssignment.sectorIndex < 0 ||
|
||||
rowOrbitAssignment.sectorIndex >= columnCount
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
configs.push({
|
||||
footprint,
|
||||
assignment: rowOrbitAssignment,
|
||||
rowIndex: rowOrbitAssignment.ringIndex,
|
||||
columnIndex: rowOrbitAssignment.sectorIndex,
|
||||
columnCount,
|
||||
band: resolveRowOrbitBand(rowOrbitAssignment.ringIndex, rowCount, middleRowIndex),
|
||||
});
|
||||
}
|
||||
|
||||
return configs;
|
||||
}
|
||||
|
||||
function normalizeRowOrbitAssignment(
|
||||
assignment: GraphOwnerSlotAssignment,
|
||||
ownerCount: number,
|
||||
rowCounts: readonly number[],
|
||||
options: { useLegacySixTwoRowAssignments?: boolean } = {}
|
||||
): GraphOwnerSlotAssignment | null {
|
||||
if (
|
||||
options.useLegacySixTwoRowAssignments === true &&
|
||||
ownerCount === 6 &&
|
||||
assignment.ringIndex === 1 &&
|
||||
assignment.sectorIndex >= 0 &&
|
||||
assignment.sectorIndex < 3
|
||||
) {
|
||||
return {
|
||||
ringIndex: 2,
|
||||
sectorIndex: assignment.sectorIndex,
|
||||
};
|
||||
}
|
||||
|
||||
const directColumnCount = rowCounts[assignment.ringIndex];
|
||||
if (
|
||||
directColumnCount != null &&
|
||||
assignment.sectorIndex >= 0 &&
|
||||
assignment.sectorIndex < directColumnCount
|
||||
) {
|
||||
return assignment;
|
||||
}
|
||||
|
||||
if (
|
||||
ownerCount === 6 &&
|
||||
assignment.ringIndex === 0 &&
|
||||
assignment.sectorIndex >= 0 &&
|
||||
assignment.sectorIndex < 6
|
||||
) {
|
||||
return {
|
||||
ringIndex: assignment.sectorIndex < 3 ? 0 : 2,
|
||||
sectorIndex: assignment.sectorIndex % 3,
|
||||
};
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function shouldNormalizeLegacySixTwoRowAssignments(
|
||||
ownerCount: number,
|
||||
assignments: readonly GraphOwnerSlotAssignment[]
|
||||
): boolean {
|
||||
if (ownerCount !== 6 || assignments.length !== ownerCount) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return assignments.some(
|
||||
(assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
|
||||
);
|
||||
}
|
||||
|
||||
function resolveRowOrbitBand(
|
||||
rowIndex: number,
|
||||
rowCount: number,
|
||||
middleRowIndex: number
|
||||
): RowOrbitSlotConfig['band'] {
|
||||
if (middleRowIndex >= 0) {
|
||||
if (rowIndex < middleRowIndex) {
|
||||
return 'top';
|
||||
}
|
||||
return rowIndex === middleRowIndex ? 'middle' : 'bottom';
|
||||
}
|
||||
return rowIndex < rowCount / 2 ? 'top' : 'bottom';
|
||||
}
|
||||
|
||||
function buildRowOrbitSlotFrames(
|
||||
slotConfigs: readonly RowOrbitSlotConfig[],
|
||||
rowCounts: readonly number[],
|
||||
runtimeCentralExclusion: StableRect
|
||||
): SlotFrame[] {
|
||||
const rowConfigs = groupRowOrbitSlotConfigs(slotConfigs, rowCounts.length);
|
||||
const middleRowIndex = rowCounts.length === 3 ? 1 : -1;
|
||||
const rowTopByIndex = resolveRowOrbitRowTops(rowConfigs, middleRowIndex, runtimeCentralExclusion);
|
||||
const framesByOwnerId = new Map<string, SlotFrame>();
|
||||
const fallbackColumnWidth = Math.max(...slotConfigs.map((config) => config.footprint.slotWidth));
|
||||
|
||||
for (const row of rowConfigs) {
|
||||
if (row.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (row[0]?.band === 'middle') {
|
||||
for (const config of row) {
|
||||
const ownerX =
|
||||
config.columnIndex === 0
|
||||
? runtimeCentralExclusion.left - ROW_ORBIT_CENTRAL_GAP - config.footprint.slotWidth / 2
|
||||
: runtimeCentralExclusion.right +
|
||||
ROW_ORBIT_CENTRAL_GAP +
|
||||
config.footprint.slotWidth / 2;
|
||||
framesByOwnerId.set(
|
||||
config.footprint.ownerId,
|
||||
buildSlotFrameAtOwnerAnchor(config.footprint, config.assignment, ownerX, 0)
|
||||
);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const rowTop = rowTopByIndex.get(row[0]!.rowIndex) ?? 0;
|
||||
const columnCount = rowCounts[row[0]!.rowIndex] ?? row.length;
|
||||
const columnWidths = resolveRowOrbitColumnWidths(row, columnCount, fallbackColumnWidth);
|
||||
let nextLeft = -getRowOrbitRowWidth(columnWidths) / 2;
|
||||
for (const config of row) {
|
||||
const ownerX =
|
||||
nextLeft +
|
||||
columnWidths.slice(0, config.columnIndex).reduce((sum, width) => sum + width, 0) +
|
||||
config.columnIndex * ROW_ORBIT_HORIZONTAL_GAP +
|
||||
columnWidths[config.columnIndex]! / 2;
|
||||
const ownerY = rowTop + getOwnerAnchorTopOffset();
|
||||
framesByOwnerId.set(
|
||||
config.footprint.ownerId,
|
||||
buildSlotFrameAtOwnerAnchor(config.footprint, config.assignment, ownerX, ownerY)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return slotConfigs.flatMap((config) => {
|
||||
const frame = framesByOwnerId.get(config.footprint.ownerId);
|
||||
return frame ? [frame] : [];
|
||||
});
|
||||
}
|
||||
|
||||
function groupRowOrbitSlotConfigs(
|
||||
slotConfigs: readonly RowOrbitSlotConfig[],
|
||||
rowCount: number
|
||||
): RowOrbitSlotConfig[][] {
|
||||
const rows: RowOrbitSlotConfig[][] = Array.from({ length: rowCount }, () => []);
|
||||
for (const config of slotConfigs) {
|
||||
rows[config.rowIndex]!.push(config);
|
||||
}
|
||||
for (const row of rows) {
|
||||
row.sort((left, right) => left.columnIndex - right.columnIndex);
|
||||
}
|
||||
return rows;
|
||||
}
|
||||
|
||||
function resolveRowOrbitRowTops(
|
||||
rowConfigs: readonly (readonly RowOrbitSlotConfig[])[],
|
||||
middleRowIndex: number,
|
||||
runtimeCentralExclusion: StableRect
|
||||
): Map<number, number> {
|
||||
const topByRowIndex = new Map<number, number>();
|
||||
let nextTopRowBottom = runtimeCentralExclusion.top - ROW_ORBIT_CENTRAL_GAP;
|
||||
for (
|
||||
let rowIndex = middleRowIndex >= 0 ? middleRowIndex - 1 : rowConfigs.length / 2 - 1;
|
||||
rowIndex >= 0;
|
||||
rowIndex -= 1
|
||||
) {
|
||||
const row = rowConfigs[rowIndex] ?? [];
|
||||
if (row.length === 0) {
|
||||
continue;
|
||||
}
|
||||
const rowHeight = getRowOrbitRowHeight(row);
|
||||
const rowTop = nextTopRowBottom - rowHeight;
|
||||
topByRowIndex.set(rowIndex, rowTop);
|
||||
nextTopRowBottom = rowTop - ROW_ORBIT_VERTICAL_GAP;
|
||||
}
|
||||
|
||||
let nextBottomRowTop = runtimeCentralExclusion.bottom + ROW_ORBIT_CENTRAL_GAP;
|
||||
for (
|
||||
let rowIndex = middleRowIndex >= 0 ? middleRowIndex + 1 : Math.ceil(rowConfigs.length / 2);
|
||||
rowIndex < rowConfigs.length;
|
||||
rowIndex += 1
|
||||
) {
|
||||
const row = rowConfigs[rowIndex] ?? [];
|
||||
if (row.length === 0) {
|
||||
continue;
|
||||
}
|
||||
topByRowIndex.set(rowIndex, nextBottomRowTop);
|
||||
nextBottomRowTop += getRowOrbitRowHeight(row) + ROW_ORBIT_VERTICAL_GAP;
|
||||
}
|
||||
|
||||
return topByRowIndex;
|
||||
}
|
||||
|
||||
function resolveRowOrbitColumnWidths(
|
||||
row: readonly RowOrbitSlotConfig[],
|
||||
columnCount: number,
|
||||
fallbackColumnWidth: number
|
||||
): number[] {
|
||||
const columnWidths = Array.from({ length: columnCount }, () => fallbackColumnWidth);
|
||||
for (const config of row) {
|
||||
columnWidths[config.columnIndex] = Math.max(
|
||||
columnWidths[config.columnIndex] ?? fallbackColumnWidth,
|
||||
config.footprint.slotWidth
|
||||
);
|
||||
}
|
||||
return columnWidths;
|
||||
}
|
||||
|
||||
function getRowOrbitRowWidth(columnWidths: readonly number[]): number {
|
||||
return (
|
||||
columnWidths.reduce((sum, width) => sum + width, 0) +
|
||||
Math.max(0, columnWidths.length - 1) * ROW_ORBIT_HORIZONTAL_GAP
|
||||
);
|
||||
}
|
||||
|
||||
function getRowOrbitRowHeight(row: readonly RowOrbitSlotConfig[]): number {
|
||||
return Math.max(...row.map((config) => config.footprint.slotHeight));
|
||||
}
|
||||
|
||||
function planGridUnderLeadOwnerSlots(
|
||||
|
|
@ -1012,16 +1564,14 @@ function planGridUnderLeadOwnerSlots(
|
|||
const frames: SlotFrame[] = [];
|
||||
const centralBlock = unionRects([...centralCollisionRects]);
|
||||
let rowTop = centralBlock.bottom + GRID_UNDER_LEAD_LEAD_GAP;
|
||||
const columnCount = getGridUnderLeadColumnCount(ownerFootprints.length);
|
||||
|
||||
for (
|
||||
let rowStartIndex = 0;
|
||||
rowStartIndex < ownerFootprints.length;
|
||||
rowStartIndex += GRID_UNDER_LEAD_COLUMN_COUNT
|
||||
rowStartIndex += columnCount
|
||||
) {
|
||||
const rowFootprints = ownerFootprints.slice(
|
||||
rowStartIndex,
|
||||
rowStartIndex + GRID_UNDER_LEAD_COLUMN_COUNT
|
||||
);
|
||||
const rowFootprints = ownerFootprints.slice(rowStartIndex, rowStartIndex + columnCount);
|
||||
const rowWidth =
|
||||
rowFootprints.reduce((sum, footprint) => sum + footprint.slotWidth, 0) +
|
||||
Math.max(0, rowFootprints.length - 1) * SLOT_GEOMETRY.slotHorizontalGap;
|
||||
|
|
@ -1035,7 +1585,7 @@ function planGridUnderLeadOwnerSlots(
|
|||
buildSlotFrameAtOwnerAnchor(
|
||||
footprint,
|
||||
{
|
||||
ringIndex: Math.floor(rowStartIndex / GRID_UNDER_LEAD_COLUMN_COUNT),
|
||||
ringIndex: Math.floor(rowStartIndex / columnCount),
|
||||
sectorIndex: columnIndex,
|
||||
},
|
||||
ownerX,
|
||||
|
|
@ -1051,11 +1601,17 @@ function planGridUnderLeadOwnerSlots(
|
|||
return frames;
|
||||
}
|
||||
|
||||
function getGridUnderLeadColumnCount(ownerCount: number): number {
|
||||
return ownerCount === GRID_UNDER_LEAD_WIDE_TEAM_OWNER_COUNT
|
||||
? GRID_UNDER_LEAD_WIDE_TEAM_COLUMN_COUNT
|
||||
: GRID_UNDER_LEAD_DEFAULT_COLUMN_COUNT;
|
||||
}
|
||||
|
||||
function shouldUseStrictSmallTeamCardinalLayout(
|
||||
ownerFootprints: readonly OwnerFootprint[],
|
||||
layout?: GraphLayoutPort
|
||||
): boolean {
|
||||
if (ownerFootprints.length === 0 || ownerFootprints.length > 4) {
|
||||
if (ownerFootprints.length === 0 || ownerFootprints.length > 6) {
|
||||
return false;
|
||||
}
|
||||
|
||||
|
|
@ -1090,7 +1646,7 @@ function planStrictSmallTeamOwnerSlots(
|
|||
runtimeCentralExclusion: StableRect,
|
||||
layout?: GraphLayoutPort
|
||||
): SlotFrame[] | null {
|
||||
if (ownerFootprints.length === 0 || ownerFootprints.length > 4) {
|
||||
if (ownerFootprints.length === 0 || ownerFootprints.length > 6) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
|
@ -1098,13 +1654,16 @@ function planStrictSmallTeamOwnerSlots(
|
|||
if (!preset || preset.length !== ownerFootprints.length) {
|
||||
return null;
|
||||
}
|
||||
const vectorByAssignmentKey = new Map(
|
||||
preset.map((slot) => [buildAssignmentKey(slot.assignment), slot.vector] as const)
|
||||
);
|
||||
|
||||
const slotConfigs = ownerFootprints.map((footprint) => {
|
||||
const assignment = layout?.slotAssignments?.[footprint.ownerId];
|
||||
if (!assignment) {
|
||||
return null;
|
||||
}
|
||||
const vector = SMALL_TEAM_CARDINAL_VECTOR_BY_ASSIGNMENT_KEY.get(buildAssignmentKey(assignment));
|
||||
const vector = vectorByAssignmentKey.get(buildAssignmentKey(assignment));
|
||||
if (!vector) {
|
||||
return null;
|
||||
}
|
||||
|
|
@ -1119,72 +1678,164 @@ function planStrictSmallTeamOwnerSlots(
|
|||
return null;
|
||||
}
|
||||
|
||||
const baseRadiusByAxis = resolveStrictSmallTeamRadiusByAxis(
|
||||
return packStrictSmallTeamOwnerSlots(
|
||||
slotConfigs.map((slot) => slot!),
|
||||
centralCollisionRects,
|
||||
runtimeCentralExclusion
|
||||
);
|
||||
}
|
||||
|
||||
for (let iteration = 0; iteration < 48; iteration += 1) {
|
||||
const radiusBump = iteration * SMALL_TEAM_CARDINAL_RADIUS_STEP;
|
||||
const frames = slotConfigs.map((slot) => {
|
||||
const axis = resolveStrictSmallTeamVectorAxis(slot!.vector);
|
||||
return buildSlotFrameAtRadiusWithVector(
|
||||
slot!.footprint,
|
||||
slot!.assignment,
|
||||
baseRadiusByAxis[axis] +
|
||||
(axis === 'vertical' ? SMALL_TEAM_CARDINAL_VERTICAL_PADDING : 0) +
|
||||
radiusBump,
|
||||
slot!.vector
|
||||
function packStrictSmallTeamOwnerSlots(
|
||||
slotConfigs: readonly {
|
||||
footprint: OwnerFootprint;
|
||||
assignment: GraphOwnerSlotAssignment;
|
||||
vector: { x: number; y: number };
|
||||
}[],
|
||||
centralCollisionRects: readonly StableRect[],
|
||||
runtimeCentralExclusion: StableRect
|
||||
): SlotFrame[] | null {
|
||||
const radii = slotConfigs.map((slot) =>
|
||||
resolveMinimumDirectionalRadiusForVector({
|
||||
vector: slot.vector,
|
||||
footprint: slot.footprint,
|
||||
centralCollisionRects,
|
||||
runtimeCentralExclusion,
|
||||
})
|
||||
);
|
||||
});
|
||||
const allValid = frames.every((frame, frameIndex) =>
|
||||
isSlotFramePlacementValid(
|
||||
frame,
|
||||
frames.filter((_, index) => index !== frameIndex),
|
||||
centralCollisionRects
|
||||
)
|
||||
|
||||
for (let iteration = 0; iteration < STRICT_SMALL_TEAM_MAX_PACKING_ITERATIONS; iteration += 1) {
|
||||
const frames = buildStrictSmallTeamFrames(slotConfigs, radii);
|
||||
const invalidCentralIndex = frames.findIndex((frame) =>
|
||||
rectOverlapsAnyCentralRect(frame.bounds, centralCollisionRects)
|
||||
);
|
||||
if (allValid) {
|
||||
if (invalidCentralIndex >= 0) {
|
||||
radii[invalidCentralIndex] += STRICT_SMALL_TEAM_RADIUS_STEP;
|
||||
continue;
|
||||
}
|
||||
|
||||
const conflict = findFirstOwnerSlotFrameConflict(frames);
|
||||
if (!conflict) {
|
||||
return frames;
|
||||
}
|
||||
|
||||
const nextLeftRadius = resolveMinimumRadiusAvoidingFrame({
|
||||
slotConfig: slotConfigs[conflict.leftIndex]!,
|
||||
currentRadius: radii[conflict.leftIndex]!,
|
||||
otherFrame: frames[conflict.rightIndex]!,
|
||||
centralCollisionRects,
|
||||
});
|
||||
const nextRightRadius = resolveMinimumRadiusAvoidingFrame({
|
||||
slotConfig: slotConfigs[conflict.rightIndex]!,
|
||||
currentRadius: radii[conflict.rightIndex]!,
|
||||
otherFrame: frames[conflict.leftIndex]!,
|
||||
centralCollisionRects,
|
||||
});
|
||||
|
||||
if (nextLeftRadius == null && nextRightRadius == null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const leftIncrease =
|
||||
nextLeftRadius == null
|
||||
? Number.POSITIVE_INFINITY
|
||||
: nextLeftRadius - radii[conflict.leftIndex]!;
|
||||
const rightIncrease =
|
||||
nextRightRadius == null
|
||||
? Number.POSITIVE_INFINITY
|
||||
: nextRightRadius - radii[conflict.rightIndex]!;
|
||||
|
||||
if (leftIncrease <= rightIncrease) {
|
||||
radii[conflict.leftIndex] = nextLeftRadius!;
|
||||
} else {
|
||||
radii[conflict.rightIndex] = nextRightRadius!;
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveStrictSmallTeamRadiusByAxis(
|
||||
function buildStrictSmallTeamFrames(
|
||||
slotConfigs: readonly {
|
||||
footprint: OwnerFootprint;
|
||||
assignment: GraphOwnerSlotAssignment;
|
||||
vector: { x: number; y: number };
|
||||
}[],
|
||||
centralCollisionRects: readonly StableRect[],
|
||||
runtimeCentralExclusion: StableRect
|
||||
): Record<'horizontal' | 'vertical', number> {
|
||||
const radiusByAxis = {
|
||||
horizontal: 0,
|
||||
vertical: 0,
|
||||
radii: readonly number[]
|
||||
): SlotFrame[] {
|
||||
return slotConfigs.map((slot, index) =>
|
||||
buildSlotFrameAtRadiusWithVector(
|
||||
slot.footprint,
|
||||
slot.assignment,
|
||||
radii[index] ?? 0,
|
||||
slot.vector
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function findFirstOwnerSlotFrameConflict(
|
||||
frames: readonly SlotFrame[]
|
||||
): { leftIndex: number; rightIndex: number } | null {
|
||||
for (const [leftIndex, left] of frames.entries()) {
|
||||
for (let rightIndex = leftIndex + 1; rightIndex < frames.length; rightIndex += 1) {
|
||||
const right = frames[rightIndex]!;
|
||||
if (ownerSlotFramesOverlap(left.bounds, right.bounds)) {
|
||||
return { leftIndex, rightIndex };
|
||||
}
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function resolveMinimumRadiusAvoidingFrame(args: {
|
||||
slotConfig: {
|
||||
footprint: OwnerFootprint;
|
||||
assignment: GraphOwnerSlotAssignment;
|
||||
vector: { x: number; y: number };
|
||||
};
|
||||
currentRadius: number;
|
||||
otherFrame: SlotFrame;
|
||||
centralCollisionRects: readonly StableRect[];
|
||||
}): number | null {
|
||||
const canPlaceAtRadius = (radius: number): boolean => {
|
||||
const frame = buildSlotFrameAtRadiusWithVector(
|
||||
args.slotConfig.footprint,
|
||||
args.slotConfig.assignment,
|
||||
radius,
|
||||
args.slotConfig.vector
|
||||
);
|
||||
return (
|
||||
!rectOverlapsAnyCentralRect(frame.bounds, args.centralCollisionRects) &&
|
||||
!ownerSlotFramesOverlap(frame.bounds, args.otherFrame.bounds)
|
||||
);
|
||||
};
|
||||
|
||||
for (const slot of slotConfigs) {
|
||||
const axis = resolveStrictSmallTeamVectorAxis(slot.vector);
|
||||
const radius = resolveMinimumDirectionalRadiusForVector({
|
||||
vector: slot.vector,
|
||||
footprint: slot.footprint,
|
||||
centralCollisionRects,
|
||||
runtimeCentralExclusion,
|
||||
});
|
||||
radiusByAxis[axis] = Math.max(radiusByAxis[axis], radius);
|
||||
if (canPlaceAtRadius(args.currentRadius)) {
|
||||
return args.currentRadius;
|
||||
}
|
||||
|
||||
return radiusByAxis;
|
||||
let low = args.currentRadius;
|
||||
let high = Math.max(args.currentRadius + STRICT_SMALL_TEAM_RADIUS_STEP, args.currentRadius * 1.1);
|
||||
let expansionCount = 0;
|
||||
while (!canPlaceAtRadius(high) && expansionCount < 24) {
|
||||
low = high;
|
||||
high = Math.max(high + STRICT_SMALL_TEAM_RADIUS_STEP, high * 1.25);
|
||||
expansionCount += 1;
|
||||
}
|
||||
|
||||
function resolveStrictSmallTeamVectorAxis(vector: {
|
||||
x: number;
|
||||
y: number;
|
||||
}): 'horizontal' | 'vertical' {
|
||||
return Math.abs(vector.x) >= Math.abs(vector.y) ? 'horizontal' : 'vertical';
|
||||
if (!canPlaceAtRadius(high)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
for (let iteration = 0; iteration < 24; iteration += 1) {
|
||||
const mid = (low + high) / 2;
|
||||
if (canPlaceAtRadius(mid)) {
|
||||
high = mid;
|
||||
} else {
|
||||
low = mid;
|
||||
}
|
||||
}
|
||||
|
||||
return Math.ceil(high + STRICT_SMALL_TEAM_RADIUS_EPSILON);
|
||||
}
|
||||
|
||||
function buildPreferredAssignmentsMap(
|
||||
|
|
|
|||
|
|
@ -80,7 +80,16 @@ export interface TeamGraphData extends TeamViewSnapshot {
|
|||
function toGraphLaunchVisualState(
|
||||
visualState: ReturnType<typeof buildMemberLaunchPresentation>['launchVisualState'] | undefined
|
||||
): GraphNode['launchVisualState'] {
|
||||
return visualState === 'bootstrap_stalled' ? 'runtime_pending' : (visualState ?? undefined);
|
||||
if (!visualState) {
|
||||
return undefined;
|
||||
}
|
||||
if (visualState === 'bootstrap_stalled') {
|
||||
return 'runtime_pending';
|
||||
}
|
||||
if (visualState === 'starting_stale') {
|
||||
return 'spawning';
|
||||
}
|
||||
return visualState;
|
||||
}
|
||||
|
||||
export class TeamGraphAdapter {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@ export const RecentProjectCard = ({
|
|||
onClick={onClick}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
className="bg-surface/50 group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-l-[3px] border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis hover:bg-surface-raised"
|
||||
className="bg-surface/50 group relative flex min-h-[120px] flex-col overflow-hidden rounded-lg border border-border p-4 text-left transition-all duration-300 hover:border-border-emphasis hover:bg-surface-raised"
|
||||
style={{
|
||||
borderLeftColor: color.border,
|
||||
boxShadow: isHovered ? `inset 3px 0 12px -4px ${color.glow}` : undefined,
|
||||
|
|
|
|||
|
|
@ -18,6 +18,7 @@ process.env.UV_THREADPOOL_SIZE ??= '16';
|
|||
|
||||
// Keep userData stable before any integration can initialize Electron storage.
|
||||
// Sentry must stay near the top to capture early errors after storage migration.
|
||||
// eslint-disable-next-line simple-import-sort/imports -- userData migration must run before Sentry initializes Electron storage.
|
||||
import { earlyElectronUserDataMigrationResult } from './bootstrapUserDataMigration';
|
||||
import './sentry';
|
||||
|
||||
|
|
@ -76,8 +77,9 @@ import {
|
|||
} from '@main/services/team/TeamMcpConfigBuilder';
|
||||
import { TeamTranscriptProjectResolver } from '@main/services/team/TeamTranscriptProjectResolver';
|
||||
import { killTrackedCliProcesses } from '@main/utils/childProcess';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import {
|
||||
APP_STARTUP_GET_STATUS,
|
||||
APP_STARTUP_PROGRESS,
|
||||
CONTEXT_CHANGED,
|
||||
SCHEDULE_CHANGE,
|
||||
SKILLS_CHANGED,
|
||||
|
|
@ -105,6 +107,7 @@ import { join } from 'path';
|
|||
|
||||
import { cleanupEditorState, setEditorMainWindow } from './ipc/editor';
|
||||
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
|
||||
import { registerRendererLogHandlers } from './ipc/rendererLogs';
|
||||
import { setReviewMainWindow } from './ipc/review';
|
||||
import { setTmuxMainWindow } from './ipc/tmux';
|
||||
import {
|
||||
|
|
@ -209,7 +212,7 @@ import {
|
|||
} from './services';
|
||||
|
||||
import type { FileChangeEvent } from '@main/types';
|
||||
import type { TeamChangeEvent } from '@shared/types';
|
||||
import type { AppStartupStatus, AppStartupStep, TeamChangeEvent } from '@shared/types';
|
||||
|
||||
const logger = createLogger('App');
|
||||
let openCodeLifecycleBridge: OpenCodeReadinessBridge | null = null;
|
||||
|
|
@ -256,17 +259,27 @@ const INBOX_NOTIFY_DEBOUNCE_MS = 500;
|
|||
/** Messages sent from our UI (user_sent) — suppress notifications for these. */
|
||||
const suppressedSources = new Set(['user_sent']);
|
||||
|
||||
async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapterRegistry> {
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve();
|
||||
async function createOpenCodeRuntimeAdapterRegistry(
|
||||
reportProgress: (phase: string, message: string) => void = () => undefined
|
||||
): Promise<TeamRuntimeAdapterRegistry> {
|
||||
const binaryPath = await ClaudeBinaryResolver.resolve({
|
||||
onProgress: ({ phase, message }) => reportProgress(`runtime-${phase}`, message),
|
||||
});
|
||||
if (!binaryPath) {
|
||||
logger.warn('[OpenCode] Runtime adapter bridge disabled: orchestrator CLI binary not resolved');
|
||||
reportProgress(
|
||||
'runtime-unavailable',
|
||||
'Runtime not found. Continuing with limited launch support...'
|
||||
);
|
||||
openCodeLifecycleBridge = null;
|
||||
return new TeamRuntimeAdapterRegistry();
|
||||
}
|
||||
|
||||
reportProgress('runtime-environment', 'Preparing runtime environment...');
|
||||
const bridgeEnv = applyOpenCodeAutoUpdatePolicy({ ...process.env });
|
||||
bridgeEnv.AGENT_TEAMS_MCP_CLAUDE_DIR = getClaudeBasePath();
|
||||
try {
|
||||
reportProgress('runtime-work-sync', 'Preparing runtime work sync hooks...');
|
||||
const turnSettledEnv = await buildMemberWorkSyncRuntimeTurnSettledEnvironment({
|
||||
teamsBasePath: getTeamsBasePath(),
|
||||
provider: 'opencode',
|
||||
|
|
@ -282,7 +295,10 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
|||
);
|
||||
}
|
||||
try {
|
||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec();
|
||||
reportProgress('runtime-mcp', 'Resolving Agent Teams MCP server...');
|
||||
const mcpLaunchSpec = await resolveAgentTeamsMcpLaunchSpec({
|
||||
onProgress: ({ phase, message }) => reportProgress(`mcp-${phase}`, message),
|
||||
});
|
||||
const mcpEntry = mcpLaunchSpec.args[0];
|
||||
if (mcpEntry) {
|
||||
bridgeEnv.CLAUDE_MULTIMODEL_AGENT_TEAMS_MCP_COMMAND = mcpLaunchSpec.command;
|
||||
|
|
@ -297,6 +313,7 @@ async function createOpenCodeRuntimeAdapterRegistry(): Promise<TeamRuntimeAdapte
|
|||
);
|
||||
}
|
||||
|
||||
reportProgress('runtime-bridge', 'Preparing OpenCode bridge...');
|
||||
const bridgeClient = new OpenCodeBridgeCommandClient({
|
||||
binaryPath,
|
||||
tempDirectory: join(app.getPath('temp'), 'claude-team-opencode-bridge'),
|
||||
|
|
@ -624,6 +641,11 @@ let teamBackupService: TeamBackupService | null = null;
|
|||
let branchStatusService: BranchStatusService | null = null;
|
||||
let rendererRecoveryTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
let rendererRecoveryAttempts = 0;
|
||||
let servicesReady = false;
|
||||
let rendererDidFinishLoad = false;
|
||||
let fileWatcherStartupStarted = false;
|
||||
let backgroundStartupTasksStarted = false;
|
||||
let appStartupHandlersRegistered = false;
|
||||
|
||||
// File watcher event cleanup functions
|
||||
let fileChangeCleanup: (() => void) | null = null;
|
||||
|
|
@ -636,6 +658,24 @@ const startupTimers = new Set<ReturnType<typeof setTimeout>>();
|
|||
const SHUTDOWN_STEP_TIMEOUT_MS = 5_000;
|
||||
const STARTUP_RECOVERY_DELAY_MS = 10_000;
|
||||
const STARTUP_RECOVERY_CONCURRENCY = 1;
|
||||
const appStartupStartedAt = Date.now();
|
||||
let appStartupSteps: AppStartupStep[] = [
|
||||
{
|
||||
phase: 'boot',
|
||||
message: 'Starting Agent Teams AI...',
|
||||
startedAt: appStartupStartedAt,
|
||||
updatedAt: appStartupStartedAt,
|
||||
},
|
||||
];
|
||||
let appStartupStatus: AppStartupStatus = {
|
||||
phase: 'boot',
|
||||
message: 'Starting Agent Teams AI...',
|
||||
ready: false,
|
||||
error: null,
|
||||
startedAt: appStartupStartedAt,
|
||||
updatedAt: appStartupStartedAt,
|
||||
steps: appStartupSteps,
|
||||
};
|
||||
|
||||
function isShutdownStarted(): boolean {
|
||||
return shutdownComplete || shutdownPromise !== null;
|
||||
|
|
@ -653,6 +693,74 @@ function scheduleStartupTask(action: () => void, delayMs: number): void {
|
|||
startupTimers.add(timer);
|
||||
}
|
||||
|
||||
function registerAppStartupHandlers(): void {
|
||||
if (appStartupHandlersRegistered) {
|
||||
return;
|
||||
}
|
||||
appStartupHandlersRegistered = true;
|
||||
registerRendererLogHandlers(ipcMain);
|
||||
ipcMain.handle(APP_STARTUP_GET_STATUS, () => appStartupStatus);
|
||||
}
|
||||
|
||||
function cloneStartupSteps(): AppStartupStep[] {
|
||||
return appStartupSteps.map((step) => ({ ...step }));
|
||||
}
|
||||
|
||||
function updateStartupTimeline(update: Partial<AppStartupStatus>, now: number): void {
|
||||
if (!update.phase && !update.message) {
|
||||
return;
|
||||
}
|
||||
|
||||
const phase = update.phase ?? appStartupStatus.phase;
|
||||
const message = update.message ?? appStartupStatus.message;
|
||||
const current = appStartupSteps[appStartupSteps.length - 1];
|
||||
|
||||
if (current?.phase !== phase) {
|
||||
if (current && !current.finishedAt) {
|
||||
current.finishedAt = now;
|
||||
current.durationMs = now - current.startedAt;
|
||||
current.updatedAt = now;
|
||||
}
|
||||
appStartupSteps.push({
|
||||
phase,
|
||||
message,
|
||||
startedAt: now,
|
||||
updatedAt: now,
|
||||
});
|
||||
if (appStartupSteps.length > 32) {
|
||||
appStartupSteps = appStartupSteps.slice(-32);
|
||||
}
|
||||
} else {
|
||||
current.message = message;
|
||||
current.updatedAt = now;
|
||||
}
|
||||
}
|
||||
|
||||
function finishCurrentStartupStep(now: number): void {
|
||||
const current = appStartupSteps[appStartupSteps.length - 1];
|
||||
if (!current || current.finishedAt) {
|
||||
return;
|
||||
}
|
||||
current.finishedAt = now;
|
||||
current.durationMs = now - current.startedAt;
|
||||
current.updatedAt = now;
|
||||
}
|
||||
|
||||
function publishStartupStatus(update: Partial<AppStartupStatus>): void {
|
||||
const now = Date.now();
|
||||
updateStartupTimeline(update, now);
|
||||
if (update.ready === true || update.error) {
|
||||
finishCurrentStartupStep(now);
|
||||
}
|
||||
appStartupStatus = {
|
||||
...appStartupStatus,
|
||||
...update,
|
||||
updatedAt: now,
|
||||
steps: cloneStartupSteps(),
|
||||
};
|
||||
safeSendToRenderer(mainWindow, APP_STARTUP_PROGRESS, appStartupStatus);
|
||||
}
|
||||
|
||||
async function runStartupJobsBounded<T>(
|
||||
items: readonly T[],
|
||||
concurrency: number,
|
||||
|
|
@ -1063,6 +1171,12 @@ function reconfigureLocalContextForClaudeRoot(): void {
|
|||
*/
|
||||
async function initializeServices(): Promise<void> {
|
||||
logger.info('Initializing services...');
|
||||
publishStartupStatus({
|
||||
phase: 'services',
|
||||
message: 'Preparing app services...',
|
||||
ready: false,
|
||||
error: null,
|
||||
});
|
||||
|
||||
// Initialize SSH connection manager
|
||||
sshConnectionManager = new SshConnectionManager();
|
||||
|
|
@ -1167,10 +1281,20 @@ async function initializeServices(): Promise<void> {
|
|||
teamProvisioningService.setMemberRuntimeAdvisoryInvalidator((teamName, memberName) => {
|
||||
teamMemberRuntimeAdvisoryService.invalidateMemberAdvisory(teamName, memberName);
|
||||
});
|
||||
teamProvisioningService.setRuntimeAdapterRegistry(await createOpenCodeRuntimeAdapterRegistry());
|
||||
await cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
|
||||
publishStartupStatus({
|
||||
phase: 'runtime',
|
||||
message: 'Resolving local runtime...',
|
||||
});
|
||||
teamProvisioningService.setRuntimeAdapterRegistry(
|
||||
await createOpenCodeRuntimeAdapterRegistry((phase, message) =>
|
||||
publishStartupStatus({ phase, message })
|
||||
)
|
||||
);
|
||||
scheduleStartupTask(() => {
|
||||
void cleanupOpenCodeHostsForLifecycle('startup').catch((error: unknown) =>
|
||||
logger.warn(`[OpenCode] Startup host cleanup failed: ${String(error)}`)
|
||||
);
|
||||
}, STARTUP_RECOVERY_DELAY_MS);
|
||||
// Startup GC: remove stale MCP config files from previous sessions (best-effort)
|
||||
void new TeamMcpConfigBuilder().gcStaleConfigs();
|
||||
void teamDataService
|
||||
|
|
@ -1267,6 +1391,10 @@ async function initializeServices(): Promise<void> {
|
|||
const mcpInstallService = new McpInstallService(mcpAggregator, extensionsRuntimeAdapter);
|
||||
const apiKeyService = new ApiKeyService();
|
||||
providerConnectionService.setApiKeyService(apiKeyService);
|
||||
publishStartupStatus({
|
||||
phase: 'settings',
|
||||
message: 'Loading secure settings...',
|
||||
});
|
||||
await apiKeyService.syncProcessEnv(RUNTIME_MANAGED_API_KEY_ENV_VARS);
|
||||
// warmup() and ensureInstalled() are deferred to after window creation
|
||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||
|
|
@ -1448,6 +1576,11 @@ async function initializeServices(): Promise<void> {
|
|||
// startProcessHealthPolling() is deferred to after window creation
|
||||
// (did-finish-load handler) to avoid thread pool contention at startup.
|
||||
|
||||
publishStartupStatus({
|
||||
phase: 'ipc',
|
||||
message: 'Wiring app actions...',
|
||||
});
|
||||
|
||||
// Initialize IPC handlers with registry
|
||||
initializeIpcHandlers(
|
||||
contextRegistry,
|
||||
|
|
@ -1529,6 +1662,10 @@ async function initializeServices(): Promise<void> {
|
|||
}
|
||||
|
||||
logger.info('Services initialized successfully');
|
||||
publishStartupStatus({
|
||||
phase: 'readying',
|
||||
message: 'Finishing startup...',
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
@ -1717,6 +1854,85 @@ function syncTrafficLightPosition(win: BrowserWindow): void {
|
|||
safeSendToRenderer(win, WINDOW_ZOOM_FACTOR_CHANGED_CHANNEL, zoomFactor);
|
||||
}
|
||||
|
||||
function attachMainWindowToServices(): void {
|
||||
const win = mainWindow;
|
||||
if (!win || win.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
notificationManager?.setMainWindow(win);
|
||||
updaterService?.setMainWindow(win);
|
||||
cliInstallerService?.setMainWindow(win);
|
||||
setTmuxMainWindow(win);
|
||||
ptyTerminalService?.setMainWindow(win);
|
||||
teamProvisioningService?.setMainWindow(win);
|
||||
codexAccountFeature?.setMainWindow(win);
|
||||
setEditorMainWindow(win);
|
||||
setReviewMainWindow(win);
|
||||
}
|
||||
|
||||
function runPostRendererStartupTasks(): void {
|
||||
if (!servicesReady || !rendererDidFinishLoad || !mainWindow || mainWindow.isDestroyed()) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!fileWatcherStartupStarted) {
|
||||
fileWatcherStartupStarted = true;
|
||||
// Start file watchers after both the visible window and main services are ready.
|
||||
const activeContext = contextRegistry.getActive();
|
||||
if (process.platform === 'win32') {
|
||||
scheduleStartupTask(() => {
|
||||
if (!fileWatcherStartupStarted || !servicesReady || !rendererDidFinishLoad) {
|
||||
return;
|
||||
}
|
||||
activeContext.startFileWatcher();
|
||||
}, 1500);
|
||||
} else if (!isShutdownStarted()) {
|
||||
activeContext.startFileWatcher();
|
||||
}
|
||||
}
|
||||
|
||||
if (backgroundStartupTasksStarted) {
|
||||
return;
|
||||
}
|
||||
backgroundStartupTasksStarted = true;
|
||||
|
||||
if (!isShutdownStarted()) {
|
||||
scheduleStartupTask(() => void updaterService.checkForUpdates(), 3000);
|
||||
updaterService.startPeriodicCheck(60 * 60 * 1000);
|
||||
}
|
||||
|
||||
scheduleStartupTask(
|
||||
() => {
|
||||
void getTeamFsWorkerClient()
|
||||
.prewarm()
|
||||
.catch((error: unknown) =>
|
||||
logger.debug(
|
||||
`[startup] team-fs-worker prewarm skipped: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
);
|
||||
void getTeamDataWorkerClient()
|
||||
.prewarm()
|
||||
.catch((error: unknown) =>
|
||||
logger.debug(
|
||||
`[startup] team-data-worker prewarm skipped: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
);
|
||||
},
|
||||
process.platform === 'win32' ? 2500 : 1000
|
||||
);
|
||||
|
||||
scheduleStartupTask(() => {
|
||||
void teamProvisioningService.warmup();
|
||||
teamDataService.startProcessHealthPolling();
|
||||
void schedulerService?.start();
|
||||
}, 5000);
|
||||
}
|
||||
|
||||
function scheduleRendererRecovery(win: BrowserWindow): void {
|
||||
if (isShutdownStarted()) {
|
||||
return;
|
||||
|
|
@ -1759,6 +1975,7 @@ function createWindow(): void {
|
|||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
rendererDidFinishLoad = false;
|
||||
|
||||
const isMac = process.platform === 'darwin';
|
||||
const isDev = process.env.NODE_ENV === 'development';
|
||||
|
|
@ -1780,7 +1997,7 @@ function createWindow(): void {
|
|||
backgroundColor: '#1a1a1a',
|
||||
...(useNativeTitleBar ? {} : { titleBarStyle: 'hidden' as const }),
|
||||
...(isMac && { trafficLightPosition: getTrafficLightPositionForZoom(1) }),
|
||||
title: 'Agent Teams UI',
|
||||
title: 'Agent Teams AI',
|
||||
});
|
||||
markRendererUnavailable(mainWindow);
|
||||
|
||||
|
|
@ -1850,6 +2067,7 @@ function createWindow(): void {
|
|||
if (isShutdownStarted()) {
|
||||
return;
|
||||
}
|
||||
rendererDidFinishLoad = false;
|
||||
markRendererUnavailable(mainWindow);
|
||||
branchStatusService?.resetAllTracking();
|
||||
});
|
||||
|
|
@ -1874,57 +2092,8 @@ function createWindow(): void {
|
|||
}
|
||||
}, 0);
|
||||
fullscreenSyncTimer.unref?.();
|
||||
// Start file watchers now that the window is visible and responsive.
|
||||
// Deferred from initializeServices() to avoid blocking window creation
|
||||
// with fs.watch() setup (especially slow on Windows with recursive watchers).
|
||||
const activeContext = contextRegistry.getActive();
|
||||
if (process.platform === 'win32') {
|
||||
// On Windows, delay FileWatcher startup to let the renderer complete
|
||||
// its initial IPC calls without UV thread pool contention. Recursive
|
||||
// fs.watch() on NTFS saturates all 4 default UV threads.
|
||||
scheduleStartupTask(() => activeContext.startFileWatcher(), 1500);
|
||||
} else {
|
||||
if (!isShutdownStarted()) {
|
||||
activeContext.startFileWatcher();
|
||||
}
|
||||
}
|
||||
|
||||
if (!isShutdownStarted()) {
|
||||
scheduleStartupTask(() => void updaterService.checkForUpdates(), 3000);
|
||||
updaterService.startPeriodicCheck(60 * 60 * 1000);
|
||||
}
|
||||
|
||||
scheduleStartupTask(
|
||||
() => {
|
||||
void getTeamFsWorkerClient()
|
||||
.prewarm()
|
||||
.catch((error: unknown) =>
|
||||
logger.debug(
|
||||
`[startup] team-fs-worker prewarm skipped: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
);
|
||||
void getTeamDataWorkerClient()
|
||||
.prewarm()
|
||||
.catch((error: unknown) =>
|
||||
logger.debug(
|
||||
`[startup] team-data-worker prewarm skipped: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`
|
||||
)
|
||||
);
|
||||
},
|
||||
process.platform === 'win32' ? 2500 : 1000
|
||||
);
|
||||
|
||||
// Defer non-critical startup work to avoid thread pool contention.
|
||||
// The window is now visible and responsive; these run in the background.
|
||||
scheduleStartupTask(() => {
|
||||
void teamProvisioningService.warmup();
|
||||
teamDataService.startProcessHealthPolling();
|
||||
void schedulerService?.start();
|
||||
}, 5000);
|
||||
rendererDidFinishLoad = true;
|
||||
runPostRendererStartupTasks();
|
||||
}
|
||||
});
|
||||
|
||||
|
|
@ -2037,34 +2206,16 @@ function createWindow(): void {
|
|||
return;
|
||||
}
|
||||
markRendererUnavailable(mainWindow);
|
||||
rendererDidFinishLoad = false;
|
||||
fileWatcherStartupStarted = false;
|
||||
branchStatusService?.resetAllTracking();
|
||||
const activeContext = contextRegistry.getActive();
|
||||
activeContext?.stopFileWatcher();
|
||||
contextRegistry?.getActive()?.stopFileWatcher();
|
||||
if (mainWindow) {
|
||||
scheduleRendererRecovery(mainWindow);
|
||||
}
|
||||
});
|
||||
|
||||
// Set main window reference for notification manager and updater
|
||||
if (notificationManager) {
|
||||
notificationManager.setMainWindow(mainWindow);
|
||||
}
|
||||
if (updaterService) {
|
||||
updaterService.setMainWindow(mainWindow);
|
||||
}
|
||||
if (cliInstallerService) {
|
||||
cliInstallerService.setMainWindow(mainWindow);
|
||||
}
|
||||
setTmuxMainWindow(mainWindow);
|
||||
if (ptyTerminalService) {
|
||||
ptyTerminalService.setMainWindow(mainWindow);
|
||||
}
|
||||
if (teamProvisioningService) {
|
||||
teamProvisioningService.setMainWindow(mainWindow);
|
||||
}
|
||||
codexAccountFeature?.setMainWindow(mainWindow);
|
||||
setEditorMainWindow(mainWindow);
|
||||
setReviewMainWindow(mainWindow);
|
||||
attachMainWindowToServices();
|
||||
|
||||
logger.info('Main window created');
|
||||
}
|
||||
|
|
@ -2074,18 +2225,14 @@ function createWindow(): void {
|
|||
*/
|
||||
void app.whenReady().then(async () => {
|
||||
logger.info('App ready, initializing...');
|
||||
|
||||
// Pre-warm interactive shell env cache (non-blocking).
|
||||
// On macOS, Finder-launched apps get a minimal PATH. This resolves the user's
|
||||
// full shell PATH (nvm, homebrew, .local/bin, etc.) in the background so that
|
||||
// CliInstallerService.getStatus() and other services get cached results instantly.
|
||||
void resolveInteractiveShellEnv();
|
||||
registerAppStartupHandlers();
|
||||
|
||||
try {
|
||||
// Initialize services first
|
||||
await initializeServices();
|
||||
publishStartupStatus({
|
||||
phase: 'electron-ready',
|
||||
message: 'Opening window...',
|
||||
});
|
||||
|
||||
// Apply configuration settings
|
||||
const config = configManager.getConfig();
|
||||
|
||||
// Sync Sentry telemetry opt-in flag from persisted config
|
||||
|
|
@ -2109,9 +2256,19 @@ void app.whenReady().then(async () => {
|
|||
// so we avoid runtime setIcon calls that can fail and block startup.
|
||||
}
|
||||
|
||||
// Then create window
|
||||
createWindow();
|
||||
|
||||
await initializeServices();
|
||||
servicesReady = true;
|
||||
attachMainWindowToServices();
|
||||
publishStartupStatus({
|
||||
phase: 'ready',
|
||||
message: 'Ready',
|
||||
ready: true,
|
||||
error: null,
|
||||
});
|
||||
runPostRendererStartupTasks();
|
||||
|
||||
// Listen for notification click events
|
||||
notificationManager.on('notification-clicked', (_error) => {
|
||||
if (isShutdownStarted()) {
|
||||
|
|
@ -2124,6 +2281,12 @@ void app.whenReady().then(async () => {
|
|||
});
|
||||
} catch (error) {
|
||||
logger.error('Startup initialization failed:', error);
|
||||
publishStartupStatus({
|
||||
phase: 'failed',
|
||||
message: 'Startup failed',
|
||||
ready: false,
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
});
|
||||
if (!mainWindow) {
|
||||
createWindow();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,7 @@ const lastHeartbeatWarnedAtByWebContentsId = new Map<number, number>();
|
|||
const hasReceivedHeartbeatByWebContentsId = new Set<number>();
|
||||
let heartbeatMonitorStarted = false;
|
||||
let heartbeatMonitorInterval: ReturnType<typeof setInterval> | null = null;
|
||||
let rendererLogHandlersRegistered = false;
|
||||
|
||||
function startHeartbeatMonitor(): void {
|
||||
if (heartbeatMonitorStarted) return;
|
||||
|
|
@ -40,6 +41,10 @@ function startHeartbeatMonitor(): void {
|
|||
}
|
||||
|
||||
export function registerRendererLogHandlers(ipcMain: IpcMain): void {
|
||||
if (rendererLogHandlersRegistered) {
|
||||
return;
|
||||
}
|
||||
rendererLogHandlersRegistered = true;
|
||||
startHeartbeatMonitor();
|
||||
|
||||
ipcMain.on(RENDERER_LOG, () => {
|
||||
|
|
@ -69,6 +74,7 @@ export function removeRendererLogHandlers(ipcMain: IpcMain): void {
|
|||
ipcMain.removeAllListeners(RENDERER_LOG);
|
||||
ipcMain.removeAllListeners(RENDERER_BOOT);
|
||||
ipcMain.removeAllListeners(RENDERER_HEARTBEAT);
|
||||
rendererLogHandlersRegistered = false;
|
||||
|
||||
if (heartbeatMonitorInterval) {
|
||||
clearInterval(heartbeatMonitorInterval);
|
||||
|
|
|
|||
|
|
@ -7,6 +7,23 @@ import * as path from 'path';
|
|||
import { getDoctorInvokedCandidates } from './ClaudeDoctorProbe';
|
||||
import { getConfiguredCliFlavor } from './cliFlavor';
|
||||
|
||||
export interface ClaudeBinaryResolveProgress {
|
||||
phase: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ClaudeBinaryResolveOptions {
|
||||
onProgress?: (progress: ClaudeBinaryResolveProgress) => void;
|
||||
}
|
||||
|
||||
function emitProgress(
|
||||
options: ClaudeBinaryResolveOptions | undefined,
|
||||
phase: string,
|
||||
message: string
|
||||
): void {
|
||||
options?.onProgress?.({ phase, message });
|
||||
}
|
||||
|
||||
async function isExecutable(filePath: string): Promise<boolean> {
|
||||
if (process.platform === 'win32') {
|
||||
try {
|
||||
|
|
@ -218,34 +235,46 @@ export class ClaudeBinaryResolver {
|
|||
cacheVerifiedAt = 0;
|
||||
}
|
||||
|
||||
static async resolve(): Promise<string | null> {
|
||||
static async resolve(options: ClaudeBinaryResolveOptions = {}): Promise<string | null> {
|
||||
if (cachedPath !== undefined) {
|
||||
const now = Date.now();
|
||||
// Re-verify the cached binary still exists, but at most once per TTL
|
||||
if (cachedPath !== null && now - cacheVerifiedAt > CACHE_VERIFY_TTL_MS) {
|
||||
emitProgress(options, 'cache-verify', 'Verifying cached runtime...');
|
||||
if (await isExecutable(cachedPath)) {
|
||||
cacheVerifiedAt = now;
|
||||
emitProgress(options, 'cache-hit', 'Using cached runtime...');
|
||||
return cachedPath;
|
||||
}
|
||||
cachedPath = undefined;
|
||||
cacheVerifiedAt = 0;
|
||||
// Fall through to full resolution below
|
||||
} else {
|
||||
emitProgress(
|
||||
options,
|
||||
cachedPath ? 'cache-hit' : 'cache-miss',
|
||||
'Using cached runtime status...'
|
||||
);
|
||||
return cachedPath;
|
||||
}
|
||||
}
|
||||
if (!resolveInFlight) {
|
||||
resolveInFlight = ClaudeBinaryResolver.runResolve().finally(() => {
|
||||
resolveInFlight = ClaudeBinaryResolver.runResolve(options).finally(() => {
|
||||
resolveInFlight = null;
|
||||
});
|
||||
} else {
|
||||
emitProgress(options, 'in-flight', 'Waiting for runtime lookup...');
|
||||
}
|
||||
return resolveInFlight;
|
||||
}
|
||||
|
||||
private static async runResolve(): Promise<string | null> {
|
||||
await resolveInteractiveShellEnv();
|
||||
private static async runResolve(options: ClaudeBinaryResolveOptions): Promise<string | null> {
|
||||
await resolveInteractiveShellEnv({
|
||||
onProgress: (progress) => emitProgress(options, progress.phase, progress.message),
|
||||
});
|
||||
const enrichedPath = buildMergedCliPath(null);
|
||||
const flavor = getConfiguredCliFlavor();
|
||||
emitProgress(options, 'flavor', `Using ${flavor} runtime mode...`);
|
||||
|
||||
const overrideRaw =
|
||||
flavor === 'agent_teams_orchestrator'
|
||||
|
|
@ -253,6 +282,7 @@ export class ClaudeBinaryResolver {
|
|||
process.env.CLAUDE_CLI_PATH?.trim())
|
||||
: process.env.CLAUDE_CLI_PATH?.trim();
|
||||
if (overrideRaw) {
|
||||
emitProgress(options, 'configured-path', 'Checking configured runtime path...');
|
||||
const looksLikePath =
|
||||
path.isAbsolute(overrideRaw) || overrideRaw.includes('\\') || overrideRaw.includes('/');
|
||||
const resolvedOverride = looksLikePath
|
||||
|
|
@ -262,15 +292,18 @@ export class ClaudeBinaryResolver {
|
|||
if (resolvedOverride) {
|
||||
cachedPath = resolvedOverride;
|
||||
cacheVerifiedAt = Date.now();
|
||||
emitProgress(options, 'configured-path-found', 'Using configured runtime path...');
|
||||
return cachedPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (flavor === 'agent_teams_orchestrator') {
|
||||
emitProgress(options, 'bundled-runtime', 'Checking bundled Agent Teams runtime...');
|
||||
const bundledBinary = await resolveBundledOrchestratorBinary();
|
||||
if (bundledBinary) {
|
||||
cachedPath = bundledBinary;
|
||||
cacheVerifiedAt = Date.now();
|
||||
emitProgress(options, 'bundled-runtime-found', 'Using bundled Agent Teams runtime...');
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
|
|
@ -279,17 +312,21 @@ export class ClaudeBinaryResolver {
|
|||
// claude-multimodel on PATH without making this resolver guess a sibling
|
||||
// repo name or folder.
|
||||
const orchestratorBinaryName = 'claude-multimodel';
|
||||
emitProgress(options, 'path-runtime', 'Searching PATH for Agent Teams runtime...');
|
||||
const fromPath = await resolveFromPathEnv(orchestratorBinaryName, enrichedPath);
|
||||
if (fromPath) {
|
||||
cachedPath = fromPath;
|
||||
cacheVerifiedAt = Date.now();
|
||||
emitProgress(options, 'path-runtime-found', 'Using Agent Teams runtime from PATH...');
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
emitProgress(options, 'doctor-runtime', 'Checking runtime diagnostics fallback...');
|
||||
const fromDoctor = await resolveFromDoctorFallback(orchestratorBinaryName);
|
||||
if (fromDoctor) {
|
||||
cachedPath = fromDoctor;
|
||||
cacheVerifiedAt = Date.now();
|
||||
emitProgress(options, 'doctor-runtime-found', 'Using runtime from diagnostics fallback...');
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
|
|
@ -300,10 +337,12 @@ export class ClaudeBinaryResolver {
|
|||
}
|
||||
|
||||
const baseBinaryName = 'claude';
|
||||
emitProgress(options, 'path-claude', 'Searching PATH for Claude CLI...');
|
||||
const fromPath = await resolveFromPathEnv(baseBinaryName, enrichedPath);
|
||||
if (fromPath) {
|
||||
cachedPath = fromPath;
|
||||
cacheVerifiedAt = Date.now();
|
||||
emitProgress(options, 'path-claude-found', 'Using Claude CLI from PATH...');
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
|
|
@ -343,7 +382,11 @@ export class ClaudeBinaryResolver {
|
|||
platformBinaryNames.map((name) => path.join(dir, name))
|
||||
);
|
||||
|
||||
emitProgress(options, 'standard-locations', 'Checking standard Claude install locations...');
|
||||
const nvmCandidates = await collectNvmCandidates();
|
||||
if (nvmCandidates.length > 0) {
|
||||
emitProgress(options, 'nvm-locations', 'Checking nvm-managed Claude installs...');
|
||||
}
|
||||
const allCandidates = [...candidates, ...nvmCandidates];
|
||||
|
||||
// Check all fallback candidates in parallel for speed
|
||||
|
|
@ -358,17 +401,29 @@ export class ClaudeBinaryResolver {
|
|||
if (found) {
|
||||
cachedPath = found.path;
|
||||
cacheVerifiedAt = Date.now();
|
||||
emitProgress(
|
||||
options,
|
||||
'fallback-location-found',
|
||||
'Using Claude CLI from install locations...'
|
||||
);
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
emitProgress(options, 'doctor-claude', 'Checking Claude diagnostics fallback...');
|
||||
const fromDoctor = await resolveFromDoctorFallback(baseBinaryName);
|
||||
if (fromDoctor) {
|
||||
cachedPath = fromDoctor;
|
||||
cacheVerifiedAt = Date.now();
|
||||
emitProgress(options, 'doctor-claude-found', 'Using Claude CLI from diagnostics fallback...');
|
||||
return cachedPath;
|
||||
}
|
||||
|
||||
// Don't cache null — CLI may be installed later without app restart
|
||||
emitProgress(
|
||||
options,
|
||||
'not-found',
|
||||
'Runtime not found. Continuing with limited launch support...'
|
||||
);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -203,15 +203,27 @@ export function summarizeProcessBootstrapTransportEvents(
|
|||
export function buildProcessBootstrapPendingDiagnostic(
|
||||
summary: ProcessBootstrapTransportSummary
|
||||
): string {
|
||||
if (summary.submitted) {
|
||||
return summary.lastStage
|
||||
? `Bootstrap transport reached ${summary.lastStage}; waiting for bootstrap confirmation.`
|
||||
: 'Bootstrap transport is waiting for bootstrap confirmation.';
|
||||
? `Bootstrap prompt was submitted; waiting for bootstrap confirmation. Last transport stage: ${summary.lastStage}.`
|
||||
: 'Bootstrap prompt was submitted; waiting for bootstrap confirmation.';
|
||||
}
|
||||
|
||||
return summary.lastStage
|
||||
? `Bootstrap prompt has not been submitted yet. Last transport stage: ${summary.lastStage}.`
|
||||
: 'Bootstrap prompt has not been submitted yet.';
|
||||
}
|
||||
|
||||
export function buildProcessBootstrapTimeoutDiagnostic(
|
||||
summary: ProcessBootstrapTransportSummary
|
||||
): string {
|
||||
if (summary.submitted) {
|
||||
return summary.lastStage
|
||||
? `Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: ${summary.lastStage}`
|
||||
: 'Teammate was registered but did not bootstrap-confirm before timeout.';
|
||||
? `Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout. Last transport stage: ${summary.lastStage}`
|
||||
: 'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout.';
|
||||
}
|
||||
|
||||
return summary.lastStage
|
||||
? `Bootstrap prompt was not submitted before timeout. Last transport stage: ${summary.lastStage}`
|
||||
: 'Bootstrap prompt was not submitted before timeout.';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,6 +16,15 @@ export interface McpLaunchSpec {
|
|||
args: string[];
|
||||
}
|
||||
|
||||
export interface McpLaunchSpecResolveProgress {
|
||||
phase: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface McpLaunchSpecResolveOptions {
|
||||
onProgress?: (progress: McpLaunchSpecResolveProgress) => void;
|
||||
}
|
||||
|
||||
const MCP_SERVER_NAME = 'agent-teams';
|
||||
const MCP_CLAUDE_DIR_ENV = 'AGENT_TEAMS_MCP_CLAUDE_DIR';
|
||||
const logger = createLogger('Service:TeamMcpConfigBuilder');
|
||||
|
|
@ -158,15 +167,24 @@ export function clearResolvedNodePathForTests(): void {
|
|||
_resolvedNodePath = undefined;
|
||||
}
|
||||
|
||||
function emitProgress(
|
||||
options: McpLaunchSpecResolveOptions | undefined,
|
||||
phase: string,
|
||||
message: string
|
||||
): void {
|
||||
options?.onProgress?.({ phase, message });
|
||||
}
|
||||
|
||||
/**
|
||||
* Find the real `node` binary path. In Electron, process.execPath is the
|
||||
* Electron binary — NOT node — so we must resolve node separately.
|
||||
* Uses async execFile('node', ...) which is cross-platform (no /usr/bin/env dependency).
|
||||
*/
|
||||
async function resolveNodePath(): Promise<string> {
|
||||
async function resolveNodePath(options?: McpLaunchSpecResolveOptions): Promise<string> {
|
||||
if (_resolvedNodePath) return _resolvedNodePath;
|
||||
|
||||
try {
|
||||
emitProgress(options, 'node-runtime', 'Resolving Node.js runtime for MCP server...');
|
||||
const resolved = await new Promise<string>((resolve, reject) => {
|
||||
execFile(
|
||||
'node',
|
||||
|
|
@ -180,12 +198,14 @@ async function resolveNodePath(): Promise<string> {
|
|||
});
|
||||
if (resolved) {
|
||||
_resolvedNodePath = resolved;
|
||||
emitProgress(options, 'node-runtime-found', 'Using resolved Node.js runtime...');
|
||||
return _resolvedNodePath;
|
||||
}
|
||||
} catch {
|
||||
// node not found or timed out — use bare 'node' and let the OS resolve it
|
||||
}
|
||||
_resolvedNodePath = 'node';
|
||||
emitProgress(options, 'node-runtime-fallback', 'Using system Node.js command...');
|
||||
return _resolvedNodePath;
|
||||
}
|
||||
|
||||
|
|
@ -199,10 +219,11 @@ async function resolveNodePath(): Promise<string> {
|
|||
*
|
||||
* Returns the resolved index.js path (stable copy or resourcesPath fallback).
|
||||
*/
|
||||
async function resolvePackagedServerEntry(): Promise<string> {
|
||||
async function resolvePackagedServerEntry(options?: McpLaunchSpecResolveOptions): Promise<string> {
|
||||
const fallbackEntry = getPackagedServerEntry();
|
||||
if (!isPackagedApp()) return fallbackEntry;
|
||||
|
||||
emitProgress(options, 'packaged-server', 'Checking packaged MCP server...');
|
||||
const appVersion = getAppVersion();
|
||||
const baseDir = getMcpServerBasePath();
|
||||
const finalDir = path.join(baseDir, appVersion);
|
||||
|
|
@ -210,6 +231,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
|
|||
|
||||
// Reuse existing valid copy
|
||||
if (await hasValidServerCopy(finalDir)) {
|
||||
emitProgress(options, 'packaged-server-reuse', 'Using cached MCP server copy...');
|
||||
return finalEntry;
|
||||
}
|
||||
|
||||
|
|
@ -230,6 +252,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
|
|||
return fallbackEntry;
|
||||
}
|
||||
|
||||
emitProgress(options, 'packaged-server-copy', 'Copying MCP server to app data...');
|
||||
// Atomic: copy to temp dir, then rename to final
|
||||
const tmpDir = path.join(baseDir, `${appVersion}.tmp-${process.pid}-${randomUUID()}`);
|
||||
await fs.promises.mkdir(tmpDir, { recursive: true });
|
||||
|
|
@ -254,6 +277,7 @@ async function resolvePackagedServerEntry(): Promise<string> {
|
|||
}
|
||||
|
||||
logger.info(`MCP server copied to stable path ${finalDir} (v${appVersion})`);
|
||||
emitProgress(options, 'packaged-server-ready', 'MCP server copy is ready...');
|
||||
return finalEntry;
|
||||
} catch (error) {
|
||||
logger.warn(
|
||||
|
|
@ -265,16 +289,18 @@ async function resolvePackagedServerEntry(): Promise<string> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
||||
export async function resolveAgentTeamsMcpLaunchSpec(
|
||||
options: McpLaunchSpecResolveOptions = {}
|
||||
): Promise<McpLaunchSpec> {
|
||||
const checked: string[] = [];
|
||||
|
||||
// 1. Packaged Electron app — prefer stable copy, fall back to resourcesPath
|
||||
if (isPackagedApp()) {
|
||||
const packagedEntry = await resolvePackagedServerEntry();
|
||||
const packagedEntry = await resolvePackagedServerEntry(options);
|
||||
checked.push(packagedEntry);
|
||||
if (await pathExists(packagedEntry)) {
|
||||
return {
|
||||
command: await resolveNodePath(),
|
||||
command: await resolveNodePath(options),
|
||||
args: [packagedEntry],
|
||||
};
|
||||
}
|
||||
|
|
@ -283,12 +309,14 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
|||
|
||||
// 2. Dev mode — prefer source so pnpm dev always sees current MCP tools
|
||||
const sourceEntry = getSourceServerEntry();
|
||||
emitProgress(options, 'source-entry', 'Checking MCP source entry...');
|
||||
checked.push(sourceEntry);
|
||||
if (await pathExists(sourceEntry)) {
|
||||
emitProgress(options, 'tsx-runner', 'Resolving MCP TypeScript runner...');
|
||||
const tsxCli = await resolveWorkspaceTsxCli(checked);
|
||||
if (tsxCli) {
|
||||
return {
|
||||
command: await resolveNodePath(),
|
||||
command: await resolveNodePath(options),
|
||||
args: [tsxCli, sourceEntry],
|
||||
};
|
||||
}
|
||||
|
|
@ -296,10 +324,11 @@ export async function resolveAgentTeamsMcpLaunchSpec(): Promise<McpLaunchSpec> {
|
|||
|
||||
// 3. Dev mode fallback — use built dist when source execution is unavailable
|
||||
const builtEntry = getBuiltServerEntry();
|
||||
emitProgress(options, 'built-entry', 'Checking built MCP server entry...');
|
||||
checked.push(builtEntry);
|
||||
if (await pathExists(builtEntry)) {
|
||||
return {
|
||||
command: await resolveNodePath(),
|
||||
command: await resolveNodePath(options),
|
||||
args: [builtEntry],
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,9 +42,9 @@ import {
|
|||
getTasksBasePath,
|
||||
getTeamsBasePath,
|
||||
} from '@main/utils/pathDecoder';
|
||||
import { isPathWithinRoot } from '@main/utils/pathValidation';
|
||||
import { isProcessAlive } from '@main/utils/processHealth';
|
||||
import { killProcessByPid } from '@main/utils/processKill';
|
||||
import { isPathWithinRoot } from '@main/utils/pathValidation';
|
||||
import { resolveInteractiveShellEnv } from '@main/utils/shellEnv';
|
||||
import { shouldAutoAllow } from '@main/utils/toolApprovalRules';
|
||||
import {
|
||||
|
|
@ -157,15 +157,6 @@ import {
|
|||
parseBootstrapRuntimeProofDetail,
|
||||
validateBootstrapRuntimeProofEnvelope,
|
||||
} from './bootstrap/BootstrapProofValidation';
|
||||
import {
|
||||
buildProcessBootstrapPendingDiagnostic,
|
||||
buildProcessBootstrapTimeoutDiagnostic,
|
||||
deriveProcessTransportProjectionPhase,
|
||||
sanitizeProcessRuntimeEventFilePrefix,
|
||||
summarizeProcessBootstrapTransportEvents,
|
||||
type ProcessBootstrapTransportEvent,
|
||||
type ProcessBootstrapTransportSummary,
|
||||
} from './ProcessBootstrapTransportEvidence';
|
||||
import {
|
||||
buildNativeAppManagedBootstrapSpecs,
|
||||
type NativeAppManagedBootstrapSpec,
|
||||
|
|
@ -247,6 +238,15 @@ import {
|
|||
} from './idleNotificationMainProcessSemantics';
|
||||
import { withInboxLock } from './inboxLock';
|
||||
import { getEffectiveInboxMessageId } from './inboxMessageIdentity';
|
||||
import {
|
||||
buildProcessBootstrapPendingDiagnostic,
|
||||
buildProcessBootstrapTimeoutDiagnostic,
|
||||
deriveProcessTransportProjectionPhase,
|
||||
type ProcessBootstrapTransportEvent,
|
||||
type ProcessBootstrapTransportSummary,
|
||||
sanitizeProcessRuntimeEventFilePrefix,
|
||||
summarizeProcessBootstrapTransportEvents,
|
||||
} from './ProcessBootstrapTransportEvidence';
|
||||
import {
|
||||
boundLaunchDiagnostics,
|
||||
buildProgressLiveOutput,
|
||||
|
|
@ -289,6 +289,7 @@ import {
|
|||
sanitizeProcessCommandForDiagnostics,
|
||||
} from './TeamRuntimeLivenessResolver';
|
||||
import { TeamSentMessagesStore } from './TeamSentMessagesStore';
|
||||
import { TeamTaskActivityIntervalService } from './TeamTaskActivityIntervalService';
|
||||
import { TeamTaskReader } from './TeamTaskReader';
|
||||
import { TeamTranscriptProjectResolver } from './TeamTranscriptProjectResolver';
|
||||
|
||||
|
|
@ -5451,6 +5452,8 @@ export class TeamProvisioningService {
|
|||
| null = null;
|
||||
private readonly memberLogsFinder: TeamMemberLogsFinder;
|
||||
private readonly transcriptProjectResolver: TeamTranscriptProjectResolver;
|
||||
private readonly taskActivityIntervalService = new TeamTaskActivityIntervalService();
|
||||
private readonly crashRepairedActivityIntervalsByTeam = new Set<string>();
|
||||
private teamChangeEmitter: ((event: TeamChangeEvent) => void) | null = null;
|
||||
private helpOutputCache: string | null = null;
|
||||
private helpOutputCacheTime = 0;
|
||||
|
|
@ -5504,6 +5507,15 @@ export class TeamProvisioningService {
|
|||
this.scheduleStaleAnthropicTeamApiKeyHelperCleanup();
|
||||
}
|
||||
|
||||
private repairStaleTaskActivityIntervalsOnce(
|
||||
teamName: string,
|
||||
launchSnapshot?: PersistedTeamLaunchSnapshot | null
|
||||
): void {
|
||||
if (this.crashRepairedActivityIntervalsByTeam.has(teamName)) return;
|
||||
this.taskActivityIntervalService.repairStaleIntervalsAfterCrash(teamName, launchSnapshot);
|
||||
this.crashRepairedActivityIntervalsByTeam.add(teamName);
|
||||
}
|
||||
|
||||
private scheduleStaleAnthropicTeamApiKeyHelperCleanup(): void {
|
||||
void cleanupStaleAnthropicTeamApiKeyHelpers({
|
||||
baseClaudeDir: getClaudeBasePath(),
|
||||
|
|
@ -12255,6 +12267,20 @@ export class TeamProvisioningService {
|
|||
return;
|
||||
}
|
||||
|
||||
if (prev.runtimeAlive === true && next.runtimeAlive !== true) {
|
||||
this.taskActivityIntervalService.pauseActiveIntervalsForMember(
|
||||
run.teamName,
|
||||
memberName,
|
||||
updatedAt
|
||||
);
|
||||
} else if (prev.runtimeAlive !== true && next.runtimeAlive === true) {
|
||||
this.taskActivityIntervalService.resumeActiveIntervalsForMember(
|
||||
run.teamName,
|
||||
memberName,
|
||||
updatedAt
|
||||
);
|
||||
}
|
||||
|
||||
run.memberSpawnStatuses.set(memberName, next);
|
||||
if (
|
||||
(status === 'online' && (next.bootstrapConfirmed || livenessSource === 'process')) ||
|
||||
|
|
@ -12394,10 +12420,21 @@ export class TeamProvisioningService {
|
|||
}> {
|
||||
const readPersistedStatuses = async (resolvedRunId: string | null) => {
|
||||
const { snapshot, statuses } = await this.reconcilePersistedLaunchState(teamName);
|
||||
this.repairStaleTaskActivityIntervalsOnce(teamName, snapshot);
|
||||
const nextStatuses = await this.attachLiveRuntimeMetadataToStatuses(teamName, statuses, {
|
||||
openCodeSecondaryBootstrapPendingMembers:
|
||||
this.getOpenCodeSecondaryBootstrapPendingMemberNames(snapshot),
|
||||
});
|
||||
const runtimeObservedAt = nowIso();
|
||||
for (const [memberName, entry] of Object.entries(nextStatuses)) {
|
||||
if (entry.runtimeAlive === true) {
|
||||
this.taskActivityIntervalService.resumeActiveIntervalsForMember(
|
||||
teamName,
|
||||
memberName,
|
||||
runtimeObservedAt
|
||||
);
|
||||
}
|
||||
}
|
||||
const expectedMembers = snapshot ? this.getPersistedLaunchMemberNames(snapshot) : undefined;
|
||||
const summary = expectedMembers
|
||||
? summarizeMemberSpawnStatusRecord(expectedMembers, nextStatuses)
|
||||
|
|
@ -16886,6 +16923,10 @@ export class TeamProvisioningService {
|
|||
if (existingProvisioningRunId) {
|
||||
return { runId: existingProvisioningRunId };
|
||||
}
|
||||
const previousLaunchSnapshot = await this.launchStateStore
|
||||
.read(request.teamName)
|
||||
.catch(() => null);
|
||||
this.repairStaleTaskActivityIntervalsOnce(request.teamName, previousLaunchSnapshot);
|
||||
const stopAllGenerationAtStart = this.stopAllTeamsGeneration;
|
||||
assertAppDeterministicBootstrapEnabled();
|
||||
if (this.shouldRouteOpenCodeToRuntimeAdapter(request)) {
|
||||
|
|
@ -25806,6 +25847,7 @@ export class TeamProvisioningService {
|
|||
*/
|
||||
async stopTeam(teamName: string): Promise<void> {
|
||||
this.invalidateRuntimeSnapshotCaches(teamName);
|
||||
this.taskActivityIntervalService.pauseActiveIntervalsForTeam(teamName);
|
||||
this.stopPersistentTeamMembers(teamName);
|
||||
|
||||
const runId = this.getTrackedRunId(teamName);
|
||||
|
|
@ -26301,6 +26343,7 @@ export class TeamProvisioningService {
|
|||
if (orphanOnly.length > 0) {
|
||||
logger.info(`Cleaning up persisted teammate runtimes on shutdown: ${orphanOnly.join(', ')}`);
|
||||
for (const teamName of orphanOnly) {
|
||||
this.taskActivityIntervalService.pauseActiveIntervalsForTeam(teamName);
|
||||
this.stopPersistentTeamMembers(teamName);
|
||||
await this.cleanupAnthropicApiKeyHelperMaterialForStoppedTeam(teamName);
|
||||
}
|
||||
|
|
@ -30828,7 +30871,14 @@ export class TeamProvisioningService {
|
|||
return providerId === 'opencode' || inferTeamProviderIdFromModel(model) === 'opencode';
|
||||
});
|
||||
if (configHasOpenCodeMember) {
|
||||
return this.buildConfigLaunchCompatibilityReport(teamName, configMembers, leadProviderId);
|
||||
return this.buildConfigLaunchCompatibilityReport(
|
||||
teamName,
|
||||
configMembers,
|
||||
leadProviderId,
|
||||
{
|
||||
ignoredInboxNames: true,
|
||||
}
|
||||
);
|
||||
}
|
||||
const configMembersByName = new Map(
|
||||
configMembers.map((member) => [member.name.toLowerCase(), member] as const)
|
||||
|
|
@ -30915,7 +30965,8 @@ export class TeamProvisioningService {
|
|||
private buildConfigLaunchCompatibilityReport(
|
||||
teamName: string,
|
||||
configMembers: TeamCreateRequest['members'],
|
||||
leadProviderId?: TeamProviderId
|
||||
leadProviderId?: TeamProviderId,
|
||||
options: { ignoredInboxNames?: boolean } = {}
|
||||
): TeamLaunchCompatibilityReport {
|
||||
if (this.hasIncompleteOpenCodeLaunchCompatibilityMember(configMembers)) {
|
||||
return {
|
||||
|
|
@ -30957,7 +31008,9 @@ export class TeamProvisioningService {
|
|||
rosterSource: 'config',
|
||||
members: configMembers,
|
||||
warnings: [
|
||||
'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
|
||||
options.ignoredInboxNames
|
||||
? 'members.meta.json is missing; launch used complete config.json member metadata instead of inbox fallback to preserve mixed provider/model layout.'
|
||||
: 'members.meta.json and inboxes are empty; launch fell back to config.json members. ' +
|
||||
'Run a fresh team bootstrap to persist stable member metadata.',
|
||||
],
|
||||
blockers: [],
|
||||
|
|
|
|||
269
src/main/services/team/TeamTaskActivityIntervalService.ts
Normal file
269
src/main/services/team/TeamTaskActivityIntervalService.ts
Normal file
|
|
@ -0,0 +1,269 @@
|
|||
import { getTasksBasePath } from '@main/utils/pathDecoder';
|
||||
import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
|
||||
import { TeamTaskReader } from './TeamTaskReader';
|
||||
|
||||
import type {
|
||||
PersistedTeamLaunchMemberState,
|
||||
PersistedTeamLaunchSnapshot,
|
||||
TaskReviewInterval,
|
||||
TeamTask,
|
||||
} from '@shared/types';
|
||||
|
||||
interface ActivityIntervalResult {
|
||||
changedTasks: number;
|
||||
}
|
||||
|
||||
type MutableTeamTask = TeamTask & {
|
||||
reviewIntervals?: TaskReviewInterval[];
|
||||
};
|
||||
|
||||
const CRASH_REPAIR_GRACE_MS = 5_000;
|
||||
|
||||
function normalizeMemberName(value: string | null | undefined): string {
|
||||
return typeof value === 'string' && value.trim() ? value.trim().toLowerCase() : '';
|
||||
}
|
||||
|
||||
function parseIsoMs(value: string | null | undefined): number {
|
||||
if (!value) return 0;
|
||||
const parsed = Date.parse(value);
|
||||
return Number.isFinite(parsed) ? parsed : 0;
|
||||
}
|
||||
|
||||
function toIso(ms: number): string {
|
||||
return new Date(ms).toISOString();
|
||||
}
|
||||
|
||||
function ensureCloseIso(startedAt: string, at: string): string {
|
||||
const startedAtMs = parseIsoMs(startedAt);
|
||||
const atMs = parseIsoMs(at);
|
||||
if (startedAtMs <= 0) return at;
|
||||
if (atMs <= startedAtMs) return toIso(startedAtMs);
|
||||
return toIso(atMs);
|
||||
}
|
||||
|
||||
function crashRepairCloseIso(startedAt: string, member?: PersistedTeamLaunchMemberState): string {
|
||||
const startedAtMs = parseIsoMs(startedAt);
|
||||
const safeStartedAtMs = startedAtMs > 0 ? startedAtMs : Date.now();
|
||||
const evidenceMs = Math.max(
|
||||
parseIsoMs(member?.lastHeartbeatAt),
|
||||
parseIsoMs(member?.runtimeLastSeenAt),
|
||||
parseIsoMs(member?.lastRuntimeAliveAt)
|
||||
);
|
||||
const closeMs =
|
||||
evidenceMs > 0
|
||||
? Math.max(safeStartedAtMs, evidenceMs + CRASH_REPAIR_GRACE_MS)
|
||||
: safeStartedAtMs + CRASH_REPAIR_GRACE_MS;
|
||||
const boundedCloseMs = Math.max(safeStartedAtMs, Math.min(Date.now(), closeMs));
|
||||
return toIso(boundedCloseMs);
|
||||
}
|
||||
|
||||
function hasOpenWorkInterval(task: MutableTeamTask): boolean {
|
||||
return (
|
||||
Array.isArray(task.workIntervals) &&
|
||||
task.workIntervals.some((interval) => !interval.completedAt)
|
||||
);
|
||||
}
|
||||
|
||||
function hasOpenReviewInterval(task: MutableTeamTask, reviewer: string): boolean {
|
||||
const reviewerKey = normalizeMemberName(reviewer);
|
||||
return (
|
||||
Array.isArray(task.reviewIntervals) &&
|
||||
task.reviewIntervals.some(
|
||||
(interval) => !interval.completedAt && normalizeMemberName(interval.reviewer) === reviewerKey
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function closeOpenWorkIntervals(task: MutableTeamTask, at: string, owner?: string): boolean {
|
||||
if (!Array.isArray(task.workIntervals)) return false;
|
||||
if (owner && normalizeMemberName(task.owner) !== normalizeMemberName(owner)) return false;
|
||||
|
||||
let changed = false;
|
||||
task.workIntervals = task.workIntervals.map((interval) => {
|
||||
if (interval.completedAt) return interval;
|
||||
changed = true;
|
||||
return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) };
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function closeOpenReviewIntervals(task: MutableTeamTask, at: string, reviewer?: string): boolean {
|
||||
if (!Array.isArray(task.reviewIntervals)) return false;
|
||||
const reviewerKey = normalizeMemberName(reviewer);
|
||||
|
||||
let changed = false;
|
||||
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||
if (interval.completedAt) return interval;
|
||||
if (reviewerKey && normalizeMemberName(interval.reviewer) !== reviewerKey) return interval;
|
||||
changed = true;
|
||||
return { ...interval, completedAt: ensureCloseIso(interval.startedAt, at) };
|
||||
});
|
||||
return changed;
|
||||
}
|
||||
|
||||
function getActiveReviewActor(task: MutableTeamTask): string | null {
|
||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index];
|
||||
if (event.type === 'review_started') {
|
||||
return typeof event.actor === 'string' && event.actor.trim() ? event.actor.trim() : null;
|
||||
}
|
||||
if (
|
||||
event.type === 'review_approved' ||
|
||||
event.type === 'review_changes_requested' ||
|
||||
(event.type === 'status_changed' &&
|
||||
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted')) ||
|
||||
event.type === 'task_created'
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function readTaskFile(filePath: string): MutableTeamTask | null {
|
||||
try {
|
||||
const parsed = JSON.parse(fs.readFileSync(filePath, 'utf8')) as unknown;
|
||||
return parsed && typeof parsed === 'object' ? (parsed as MutableTeamTask) : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeTaskFile(filePath: string, task: MutableTeamTask): void {
|
||||
const tempPath = `${filePath}.${process.pid}.${Date.now()}.tmp`;
|
||||
fs.writeFileSync(tempPath, JSON.stringify(task, null, 2));
|
||||
fs.renameSync(tempPath, filePath);
|
||||
}
|
||||
|
||||
export class TeamTaskActivityIntervalService {
|
||||
private mutateTeamTasks(
|
||||
teamName: string,
|
||||
mutate: (task: MutableTeamTask) => boolean
|
||||
): ActivityIntervalResult {
|
||||
const tasksDir = path.join(getTasksBasePath(), teamName);
|
||||
let entries: string[];
|
||||
try {
|
||||
entries = fs.readdirSync(tasksDir);
|
||||
} catch {
|
||||
return { changedTasks: 0 };
|
||||
}
|
||||
|
||||
let changedTasks = 0;
|
||||
for (const fileName of entries) {
|
||||
if (!fileName.endsWith('.json') || fileName.startsWith('.')) continue;
|
||||
const filePath = path.join(tasksDir, fileName);
|
||||
const task = readTaskFile(filePath);
|
||||
if (!task) continue;
|
||||
if (!mutate(task)) continue;
|
||||
writeTaskFile(filePath, task);
|
||||
changedTasks += 1;
|
||||
}
|
||||
|
||||
if (changedTasks > 0) {
|
||||
TeamTaskReader.invalidateAllTasksCache();
|
||||
}
|
||||
return { changedTasks };
|
||||
}
|
||||
|
||||
pauseActiveIntervalsForTeam(
|
||||
teamName: string,
|
||||
at = new Date().toISOString()
|
||||
): ActivityIntervalResult {
|
||||
return this.mutateTeamTasks(teamName, (task) => {
|
||||
const changedWork = closeOpenWorkIntervals(task, at);
|
||||
const changedReview = closeOpenReviewIntervals(task, at);
|
||||
return changedWork || changedReview;
|
||||
});
|
||||
}
|
||||
|
||||
pauseActiveIntervalsForMember(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
at = new Date().toISOString()
|
||||
): ActivityIntervalResult {
|
||||
return this.mutateTeamTasks(teamName, (task) => {
|
||||
const changedWork = closeOpenWorkIntervals(task, at, memberName);
|
||||
const changedReview = closeOpenReviewIntervals(task, at, memberName);
|
||||
return changedWork || changedReview;
|
||||
});
|
||||
}
|
||||
|
||||
resumeActiveIntervalsForMember(
|
||||
teamName: string,
|
||||
memberName: string,
|
||||
at = new Date().toISOString()
|
||||
): ActivityIntervalResult {
|
||||
const memberKey = normalizeMemberName(memberName);
|
||||
if (!memberKey) return { changedTasks: 0 };
|
||||
|
||||
return this.mutateTeamTasks(teamName, (task) => {
|
||||
let changed = false;
|
||||
|
||||
if (
|
||||
task.status === 'in_progress' &&
|
||||
normalizeMemberName(task.owner) === memberKey &&
|
||||
!hasOpenWorkInterval(task)
|
||||
) {
|
||||
task.workIntervals = [
|
||||
...(Array.isArray(task.workIntervals) ? task.workIntervals : []),
|
||||
{ startedAt: at },
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
const activeReviewer = getActiveReviewActor(task);
|
||||
if (
|
||||
activeReviewer &&
|
||||
normalizeMemberName(activeReviewer) === memberKey &&
|
||||
!hasOpenReviewInterval(task, activeReviewer)
|
||||
) {
|
||||
task.reviewIntervals = [
|
||||
...(Array.isArray(task.reviewIntervals) ? task.reviewIntervals : []),
|
||||
{ reviewer: activeReviewer, startedAt: at },
|
||||
];
|
||||
changed = true;
|
||||
}
|
||||
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
|
||||
repairStaleIntervalsAfterCrash(
|
||||
teamName: string,
|
||||
launchSnapshot?: PersistedTeamLaunchSnapshot | null
|
||||
): ActivityIntervalResult {
|
||||
const memberByName = new Map<string, PersistedTeamLaunchMemberState>();
|
||||
for (const member of Object.values(launchSnapshot?.members ?? {})) {
|
||||
memberByName.set(normalizeMemberName(member.name), member);
|
||||
}
|
||||
|
||||
return this.mutateTeamTasks(teamName, (task) => {
|
||||
let changed = false;
|
||||
if (Array.isArray(task.workIntervals)) {
|
||||
const ownerMember = memberByName.get(normalizeMemberName(task.owner));
|
||||
task.workIntervals = task.workIntervals.map((interval) => {
|
||||
if (interval.completedAt) return interval;
|
||||
changed = true;
|
||||
return { ...interval, completedAt: crashRepairCloseIso(interval.startedAt, ownerMember) };
|
||||
});
|
||||
}
|
||||
|
||||
if (Array.isArray(task.reviewIntervals)) {
|
||||
task.reviewIntervals = task.reviewIntervals.map((interval) => {
|
||||
if (interval.completedAt) return interval;
|
||||
const reviewerMember = memberByName.get(normalizeMemberName(interval.reviewer));
|
||||
changed = true;
|
||||
return {
|
||||
...interval,
|
||||
completedAt: crashRepairCloseIso(interval.startedAt, reviewerMember),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
return changed;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -15,6 +15,7 @@ import type {
|
|||
TaskComment,
|
||||
TaskHistoryEvent,
|
||||
TaskRef,
|
||||
TaskReviewInterval,
|
||||
TaskWorkInterval,
|
||||
TeamTask,
|
||||
} from '@shared/types';
|
||||
|
|
@ -194,6 +195,25 @@ export class TeamTaskReader {
|
|||
completedAt: i.completedAt,
|
||||
}))
|
||||
: undefined;
|
||||
const reviewIntervals: TaskReviewInterval[] | undefined = Array.isArray(
|
||||
parsed.reviewIntervals
|
||||
)
|
||||
? (parsed.reviewIntervals as unknown[])
|
||||
.filter(
|
||||
(i): i is { reviewer: string; startedAt: string; completedAt?: string } =>
|
||||
Boolean(i) &&
|
||||
typeof i === 'object' &&
|
||||
typeof (i as Record<string, unknown>).reviewer === 'string' &&
|
||||
typeof (i as Record<string, unknown>).startedAt === 'string' &&
|
||||
((i as Record<string, unknown>).completedAt === undefined ||
|
||||
typeof (i as Record<string, unknown>).completedAt === 'string')
|
||||
)
|
||||
.map((i) => ({
|
||||
reviewer: i.reviewer,
|
||||
startedAt: i.startedAt,
|
||||
completedAt: i.completedAt,
|
||||
}))
|
||||
: undefined;
|
||||
const status = (['pending', 'in_progress', 'completed', 'deleted'] as const).includes(
|
||||
parsed.status as TeamTask['status']
|
||||
)
|
||||
|
|
@ -223,6 +243,7 @@ export class TeamTaskReader {
|
|||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||
status,
|
||||
workIntervals,
|
||||
reviewIntervals,
|
||||
historyEvents,
|
||||
blocks: Array.isArray(parsed.blocks)
|
||||
? (parsed.blocks as unknown[]).filter((id): id is string => typeof id === 'string')
|
||||
|
|
|
|||
|
|
@ -2,10 +2,10 @@ import * as fs from 'fs';
|
|||
import * as path from 'path';
|
||||
|
||||
const LEGACY_USER_DATA_DIR_NAMES = [
|
||||
'Claude Agent Teams UI',
|
||||
'claude-agent-teams-ui',
|
||||
'agent-teams-ai',
|
||||
'Agent Teams UI',
|
||||
'Claude Agent Teams UI',
|
||||
'claude-agent-teams-ui',
|
||||
'claude-devtools',
|
||||
'claude-code-context',
|
||||
] as const;
|
||||
|
|
@ -72,6 +72,9 @@ const TRANSIENT_CHROMIUM_FILE_NAMES = new Set([
|
|||
'Trust Tokens-journal',
|
||||
]);
|
||||
|
||||
const DURABLE_USER_DATA_ROOT_NAMES = new Set(['data', 'backups']);
|
||||
const PREFERRED_USER_DATA_DIR_NAME = 'agent-teams-ai';
|
||||
|
||||
const STALE_MIGRATION_TEMP_MAX_AGE_MS = 60 * 60 * 1000;
|
||||
|
||||
export function getLegacyElectronUserDataCandidates(currentPath: string): string[] {
|
||||
|
|
@ -104,6 +107,30 @@ export function migrateElectronUserDataDirectory(
|
|||
};
|
||||
}
|
||||
|
||||
const preferredExistingPath = selectPreferredElectronUserDataPath(currentPath);
|
||||
if (preferredExistingPath) {
|
||||
try {
|
||||
setLegacyElectronPaths(app, preferredExistingPath, logger);
|
||||
logger?.info(`Reusing preferred Electron userData at ${preferredExistingPath}`);
|
||||
return {
|
||||
currentPath,
|
||||
legacyPath: preferredExistingPath,
|
||||
migrated: false,
|
||||
fallbackToLegacy: false,
|
||||
reason: 'legacy-reused',
|
||||
};
|
||||
} catch (error) {
|
||||
logger?.warn(`Electron userData preferred reuse failed: ${stringifyError(error)}`);
|
||||
return {
|
||||
currentPath,
|
||||
legacyPath: preferredExistingPath,
|
||||
migrated: false,
|
||||
fallbackToLegacy: false,
|
||||
reason: 'error',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
if (directoryExists(currentPath) && directoryHasDurableUserDataEntries(currentPath)) {
|
||||
return {
|
||||
currentPath,
|
||||
|
|
@ -209,15 +236,23 @@ export function migrateElectronUserDataDirectory(
|
|||
}
|
||||
|
||||
function selectLegacyElectronUserDataPath(currentPath: string): string | null {
|
||||
const candidates = getLegacyElectronUserDataCandidates(currentPath).filter(directoryExists);
|
||||
return (
|
||||
candidates.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ??
|
||||
candidates.find((candidatePath) => directoryHasEntries(candidatePath)) ??
|
||||
candidates[0] ??
|
||||
null
|
||||
getLegacyElectronUserDataCandidates(currentPath)
|
||||
.filter(directoryExists)
|
||||
.find((candidatePath) => directoryHasDurableUserDataEntries(candidatePath)) ?? null
|
||||
);
|
||||
}
|
||||
|
||||
function selectPreferredElectronUserDataPath(currentPath: string): string | null {
|
||||
const preferredPath = path.join(path.dirname(currentPath), PREFERRED_USER_DATA_DIR_NAME);
|
||||
if (path.resolve(preferredPath) === path.resolve(currentPath)) {
|
||||
return null;
|
||||
}
|
||||
return directoryExists(preferredPath) && directoryHasDurableUserDataEntries(preferredPath)
|
||||
? preferredPath
|
||||
: null;
|
||||
}
|
||||
|
||||
function setLegacyElectronPaths(
|
||||
app: ElectronUserDataMigrationApp,
|
||||
legacyPath: string,
|
||||
|
|
@ -252,7 +287,7 @@ function copyLegacyUserDataDirectory(
|
|||
|
||||
copyDirectory(legacyPath, tempPath);
|
||||
|
||||
if (directoryExists(currentPath) && !directoryHasEntries(currentPath)) {
|
||||
if (directoryExists(currentPath) && directoryIsEmpty(currentPath)) {
|
||||
fs.rmdirSync(currentPath);
|
||||
}
|
||||
|
||||
|
|
@ -360,9 +395,9 @@ function directoryExists(targetPath: string): boolean {
|
|||
}
|
||||
}
|
||||
|
||||
function directoryHasEntries(targetPath: string): boolean {
|
||||
function directoryIsEmpty(targetPath: string): boolean {
|
||||
try {
|
||||
return fs.readdirSync(targetPath).length > 0;
|
||||
return fs.readdirSync(targetPath).length === 0;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
|
|
@ -381,6 +416,12 @@ function directoryHasDurableUserDataEntriesWithin(rootPath: string, targetPath:
|
|||
|
||||
for (const entry of entries) {
|
||||
const entryPath = path.join(targetPath, entry.name);
|
||||
const relativePath = path.relative(rootPath, entryPath);
|
||||
const rootSegment = relativePath.split(path.sep).find(Boolean);
|
||||
if (!rootSegment || !DURABLE_USER_DATA_ROOT_NAMES.has(rootSegment)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!shouldCopyElectronUserDataEntry(rootPath, entryPath)) {
|
||||
continue;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,23 @@ const SHELL_ENV_TIMEOUT_MS = 12_000;
|
|||
let cachedInteractiveShellEnv: NodeJS.ProcessEnv | null = null;
|
||||
let shellEnvResolvePromise: Promise<NodeJS.ProcessEnv> | null = null;
|
||||
|
||||
export interface ShellEnvResolveProgress {
|
||||
phase: string;
|
||||
message: string;
|
||||
}
|
||||
|
||||
export interface ShellEnvResolveOptions {
|
||||
onProgress?: (progress: ShellEnvResolveProgress) => void;
|
||||
}
|
||||
|
||||
function emitProgress(
|
||||
options: ShellEnvResolveOptions | undefined,
|
||||
phase: string,
|
||||
message: string
|
||||
): void {
|
||||
options?.onProgress?.({ phase, message });
|
||||
}
|
||||
|
||||
function parseNullSeparatedEnv(content: string): NodeJS.ProcessEnv {
|
||||
const parsed: NodeJS.ProcessEnv = {};
|
||||
const lines = content.split('\0');
|
||||
|
|
@ -95,14 +112,19 @@ async function readShellEnv(shellPath: string, args: string[]): Promise<NodeJS.P
|
|||
* Tries login shell first (`-lic`), falls back to interactive (`-ic`).
|
||||
* On Windows returns empty object. Result is cached after first success.
|
||||
*/
|
||||
export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
|
||||
export async function resolveInteractiveShellEnv(
|
||||
options: ShellEnvResolveOptions = {}
|
||||
): Promise<NodeJS.ProcessEnv> {
|
||||
if (cachedInteractiveShellEnv) {
|
||||
emitProgress(options, 'shell-env-cached', 'Using cached shell environment...');
|
||||
return cachedInteractiveShellEnv;
|
||||
}
|
||||
if (shellEnvResolvePromise) {
|
||||
emitProgress(options, 'shell-env-waiting', 'Waiting for shell environment...');
|
||||
return shellEnvResolvePromise;
|
||||
}
|
||||
if (process.platform === 'win32') {
|
||||
emitProgress(options, 'shell-env-skipped', 'Skipping shell environment on Windows...');
|
||||
cachedInteractiveShellEnv = {};
|
||||
return cachedInteractiveShellEnv;
|
||||
}
|
||||
|
|
@ -110,6 +132,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
|
|||
shellEnvResolvePromise = (async () => {
|
||||
const shellPath = process.env.SHELL || '/bin/zsh';
|
||||
try {
|
||||
emitProgress(options, 'shell-env-login', 'Reading login shell environment...');
|
||||
const loginEnv = await readShellEnv(shellPath, ['-lic', 'env -0']);
|
||||
cachedInteractiveShellEnv = loginEnv;
|
||||
return loginEnv;
|
||||
|
|
@ -117,6 +140,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
|
|||
const loginMessage = loginError instanceof Error ? loginError.message : String(loginError);
|
||||
logger.warn(`Failed to resolve login shell env: ${loginMessage}`);
|
||||
try {
|
||||
emitProgress(options, 'shell-env-interactive', 'Trying interactive shell environment...');
|
||||
const interactiveEnv = await readShellEnv(shellPath, ['-ic', 'env -0']);
|
||||
cachedInteractiveShellEnv = interactiveEnv;
|
||||
return interactiveEnv;
|
||||
|
|
@ -124,6 +148,7 @@ export async function resolveInteractiveShellEnv(): Promise<NodeJS.ProcessEnv> {
|
|||
const interactiveMessage =
|
||||
interactiveError instanceof Error ? interactiveError.message : String(interactiveError);
|
||||
logger.warn(`Failed to resolve interactive shell env: ${interactiveMessage}`);
|
||||
emitProgress(options, 'shell-env-fallback', 'Using current process environment...');
|
||||
return {};
|
||||
}
|
||||
} finally {
|
||||
|
|
|
|||
|
|
@ -220,6 +220,7 @@ interface ParsedTask {
|
|||
reviewState?: unknown;
|
||||
metadata?: { _internal?: unknown };
|
||||
workIntervals?: unknown;
|
||||
reviewIntervals?: unknown;
|
||||
historyEvents?: unknown;
|
||||
attachments?: unknown;
|
||||
sourceMessageId?: unknown;
|
||||
|
|
@ -231,6 +232,12 @@ interface RawWorkInterval {
|
|||
completedAt?: unknown;
|
||||
}
|
||||
|
||||
interface RawReviewInterval {
|
||||
reviewer?: unknown;
|
||||
startedAt?: unknown;
|
||||
completedAt?: unknown;
|
||||
}
|
||||
|
||||
interface RawHistoryEvent {
|
||||
id?: unknown;
|
||||
type?: unknown;
|
||||
|
|
@ -1215,6 +1222,27 @@ function normalizeWorkIntervals(
|
|||
}));
|
||||
}
|
||||
|
||||
function normalizeReviewIntervals(
|
||||
parsed: ParsedTask
|
||||
): { reviewer: string; startedAt: string; completedAt?: string }[] | undefined {
|
||||
if (!Array.isArray(parsed.reviewIntervals)) return undefined;
|
||||
return (parsed.reviewIntervals as unknown[])
|
||||
.filter(
|
||||
(i): i is RawReviewInterval =>
|
||||
Boolean(i) &&
|
||||
typeof i === 'object' &&
|
||||
typeof (i as RawReviewInterval).reviewer === 'string' &&
|
||||
typeof (i as RawReviewInterval).startedAt === 'string' &&
|
||||
((i as RawReviewInterval).completedAt === undefined ||
|
||||
typeof (i as RawReviewInterval).completedAt === 'string')
|
||||
)
|
||||
.map((i) => ({
|
||||
reviewer: i.reviewer as string,
|
||||
startedAt: i.startedAt as string,
|
||||
completedAt: i.completedAt as string | undefined,
|
||||
}));
|
||||
}
|
||||
|
||||
function normalizeHistoryEvents(parsed: ParsedTask): RawHistoryEvent[] | undefined {
|
||||
if (!Array.isArray(parsed.historyEvents)) return undefined;
|
||||
return (parsed.historyEvents as unknown[])
|
||||
|
|
@ -1479,6 +1507,7 @@ async function readTasksDirForTeam(
|
|||
createdBy: typeof parsed.createdBy === 'string' ? parsed.createdBy : undefined,
|
||||
status,
|
||||
workIntervals: normalizeWorkIntervals(parsed),
|
||||
reviewIntervals: normalizeReviewIntervals(parsed),
|
||||
historyEvents: normalizeHistoryEvents(parsed),
|
||||
blocks: Array.isArray(parsed.blocks) ? (parsed.blocks as unknown[]) : undefined,
|
||||
blockedBy: Array.isArray(parsed.blockedBy) ? (parsed.blockedBy as unknown[]) : undefined,
|
||||
|
|
|
|||
|
|
@ -17,6 +17,12 @@ export const RENDERER_BOOT = 'renderer:boot';
|
|||
/** Renderer -> main heartbeat (detect renderer stalls) */
|
||||
export const RENDERER_HEARTBEAT = 'renderer:heartbeat';
|
||||
|
||||
/** Renderer -> main startup status request */
|
||||
export const APP_STARTUP_GET_STATUS = 'appStartup:getStatus';
|
||||
|
||||
/** Main -> renderer startup progress update */
|
||||
export const APP_STARTUP_PROGRESS = 'appStartup:progress';
|
||||
|
||||
// =============================================================================
|
||||
// Config API Channels
|
||||
// =============================================================================
|
||||
|
|
|
|||
|
|
@ -14,6 +14,8 @@ import {
|
|||
API_KEYS_SAVE,
|
||||
API_KEYS_STORAGE_STATUS,
|
||||
APP_RELAUNCH,
|
||||
APP_STARTUP_GET_STATUS,
|
||||
APP_STARTUP_PROGRESS,
|
||||
CLI_INSTALLER_GET_PROVIDER_STATUS,
|
||||
CLI_INSTALLER_GET_STATUS,
|
||||
CLI_INSTALLER_INSTALL,
|
||||
|
|
@ -249,6 +251,7 @@ import type {
|
|||
AppConfig,
|
||||
ApplyReviewRequest,
|
||||
ApplyReviewResult,
|
||||
AppStartupStatus,
|
||||
AttachmentFileData,
|
||||
BoardTaskActivityDetailResult,
|
||||
BoardTaskActivityEntry,
|
||||
|
|
@ -480,6 +483,18 @@ const electronAPI: ElectronAPI = {
|
|||
runtimeProviderManagement: createRuntimeProviderManagementBridge(ipcRenderer),
|
||||
memberWorkSync: createMemberWorkSyncBridge(ipcRenderer),
|
||||
memberLogStream: createMemberLogStreamBridge(),
|
||||
startup: {
|
||||
getStatus: () => ipcRenderer.invoke(APP_STARTUP_GET_STATUS) as Promise<AppStartupStatus>,
|
||||
onProgress: (callback: (status: AppStartupStatus) => void): (() => void) => {
|
||||
const listener = (_event: Electron.IpcRendererEvent, status: AppStartupStatus): void => {
|
||||
callback(status);
|
||||
};
|
||||
ipcRenderer.on(APP_STARTUP_PROGRESS, listener);
|
||||
return (): void => {
|
||||
ipcRenderer.removeListener(APP_STARTUP_PROGRESS, listener);
|
||||
};
|
||||
},
|
||||
},
|
||||
getAppVersion: () => ipcRenderer.invoke('get-app-version'),
|
||||
getProjects: () => ipcRenderer.invoke('get-projects'),
|
||||
getSessions: (projectId: string) => ipcRenderer.invoke('get-sessions', projectId),
|
||||
|
|
|
|||
|
|
@ -895,7 +895,7 @@ export const TeamListView = memo(function TeamListView(): React.JSX.Element {
|
|||
key={team.teamName}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
className="group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border border-l-[3px] border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
|
||||
className="group relative flex cursor-pointer flex-col overflow-hidden rounded-lg border border-[var(--color-border)] bg-[var(--color-surface)] p-4 hover:bg-[var(--color-surface-raised)]"
|
||||
style={teamColorSet ? { borderLeftColor: teamColorSet.border } : undefined}
|
||||
onClick={() => openTeamTab(team.teamName, team.projectPath)}
|
||||
onKeyDown={(e) => {
|
||||
|
|
|
|||
|
|
@ -1380,6 +1380,18 @@ export const CreateTeamDialog = ({
|
|||
[request, launchTeam]
|
||||
);
|
||||
const modelValidationError = useMemo(() => {
|
||||
if (selectedProviderId === 'opencode') {
|
||||
if (!selectedModel.trim()) {
|
||||
return 'OpenCode lead requires a selected model.';
|
||||
}
|
||||
const activeMemberCount = soloTeam
|
||||
? 0
|
||||
: effectiveMemberDrafts.filter((member) => !member.removedAt && member.name.trim()).length;
|
||||
if (activeMemberCount === 0) {
|
||||
return 'OpenCode lead requires at least one OpenCode teammate.';
|
||||
}
|
||||
}
|
||||
|
||||
const leadError = getTeamModelSelectionError(
|
||||
selectedProviderId,
|
||||
selectedModel,
|
||||
|
|
@ -1409,7 +1421,13 @@ export const CreateTeamDialog = ({
|
|||
}
|
||||
|
||||
return null;
|
||||
}, [effectiveMemberDrafts, runtimeProviderStatusById, selectedModel, selectedProviderId]);
|
||||
}, [
|
||||
effectiveMemberDrafts,
|
||||
runtimeProviderStatusById,
|
||||
selectedModel,
|
||||
selectedProviderId,
|
||||
soloTeam,
|
||||
]);
|
||||
const leadModelIssueText = useMemo(() => {
|
||||
const issue = getProvisioningModelIssue(
|
||||
prepareChecks,
|
||||
|
|
|
|||
|
|
@ -138,8 +138,8 @@ import { TeammateRuntimeCompatibilityNotice } from './TeammateRuntimeCompatibili
|
|||
import {
|
||||
computeEffectiveTeamModel,
|
||||
formatTeamModelSummary,
|
||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL,
|
||||
OPENCODE_TEAM_LEAD_DISABLED_REASON,
|
||||
OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL,
|
||||
OPENCODE_ONE_SHOT_DISABLED_REASON,
|
||||
TeamModelSelector,
|
||||
} from './TeamModelSelector';
|
||||
import {
|
||||
|
|
@ -249,6 +249,14 @@ function getStoredTeamProvider(): TeamProviderId {
|
|||
return normalizeCreateLaunchProviderForUi(normalizeOptionalTeamProviderId(stored), true);
|
||||
}
|
||||
|
||||
function normalizeOneShotProviderForMode(
|
||||
providerId: TeamProviderId | undefined,
|
||||
multimodelEnabled: boolean
|
||||
): TeamProviderId {
|
||||
const normalizedProviderId = normalizeProviderForMode(providerId, multimodelEnabled);
|
||||
return normalizedProviderId === 'opencode' ? 'anthropic' : normalizedProviderId;
|
||||
}
|
||||
|
||||
function getStoredTeamModel(providerId: TeamProviderId): string {
|
||||
const stored = localStorage.getItem(`team:lastSelectedModel:${providerId}`);
|
||||
if (stored === null) {
|
||||
|
|
@ -412,10 +420,16 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
const [selectedProviderId, setSelectedProviderIdRaw] = useState<TeamProviderId>(() =>
|
||||
normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
||||
isLaunchMode
|
||||
? normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
||||
: normalizeOneShotProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
||||
);
|
||||
const [selectedModel, setSelectedModelRaw] = useState(() =>
|
||||
getStoredTeamModel(normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled))
|
||||
getStoredTeamModel(
|
||||
isLaunchMode
|
||||
? normalizeLeadProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
||||
: normalizeOneShotProviderForMode(getStoredTeamProvider(), multimodelEnabled)
|
||||
)
|
||||
);
|
||||
const [membersDrafts, setMembersDrafts] = useState<MemberDraft[]>([]);
|
||||
const [teammateWorktreeDefault, setTeammateWorktreeDefault] = useState(false);
|
||||
|
|
@ -623,7 +637,9 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
};
|
||||
|
||||
const setSelectedProviderId = (value: TeamProviderId): void => {
|
||||
const normalizedValue = normalizeLeadProviderForMode(value, multimodelEnabled);
|
||||
const normalizedValue = isLaunchMode
|
||||
? normalizeLeadProviderForMode(value, multimodelEnabled)
|
||||
: normalizeOneShotProviderForMode(value, multimodelEnabled);
|
||||
setSelectedProviderIdRaw(normalizedValue);
|
||||
localStorage.setItem('team:lastSelectedProvider', normalizedValue);
|
||||
if (normalizedValue !== 'anthropic') {
|
||||
|
|
@ -736,15 +752,19 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
promptDraft.setValue(schedule.launchConfig.prompt);
|
||||
setCustomCwd(schedule.launchConfig.cwd);
|
||||
setCwdMode('custom');
|
||||
const scheduleProviderId = normalizeLeadProviderForMode(
|
||||
const scheduleProviderId = normalizeOneShotProviderForMode(
|
||||
schedule.launchConfig.providerId,
|
||||
multimodelEnabled
|
||||
);
|
||||
const scheduleSourceProviderId = normalizeOptionalTeamProviderId(
|
||||
schedule.launchConfig.providerId
|
||||
);
|
||||
setSelectedProviderIdRaw(scheduleProviderId);
|
||||
setSelectedModelRaw(
|
||||
schedule.launchConfig.providerId !== 'gemini' &&
|
||||
scheduleSourceProviderId !== 'gemini' &&
|
||||
scheduleSourceProviderId !== 'opencode' &&
|
||||
scheduleProviderId ===
|
||||
normalizeLeadProviderForMode(schedule.launchConfig.providerId, true)
|
||||
normalizeOneShotProviderForMode(schedule.launchConfig.providerId, true)
|
||||
? (schedule.launchConfig.model ?? '')
|
||||
: getStoredTeamModel('anthropic')
|
||||
);
|
||||
|
|
@ -765,7 +785,7 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
setCwdMode('project');
|
||||
setSelectedProjectPath('');
|
||||
setCustomCwd('');
|
||||
const storedProviderId = normalizeLeadProviderForMode(
|
||||
const storedProviderId = normalizeOneShotProviderForMode(
|
||||
getStoredTeamProvider(),
|
||||
multimodelEnabled
|
||||
);
|
||||
|
|
@ -1825,6 +1845,18 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
cronExpression,
|
||||
]);
|
||||
const modelValidationError = useMemo(() => {
|
||||
if (isLaunchMode && selectedProviderId === 'opencode') {
|
||||
if (!selectedModel.trim()) {
|
||||
return 'OpenCode lead requires a selected model.';
|
||||
}
|
||||
const activeMemberCount = effectiveMemberDrafts.filter(
|
||||
(member) => !member.removedAt && member.name.trim()
|
||||
).length;
|
||||
if (activeMemberCount === 0) {
|
||||
return 'OpenCode lead requires at least one OpenCode teammate.';
|
||||
}
|
||||
}
|
||||
|
||||
const leadError = getTeamModelSelectionError(
|
||||
selectedProviderId,
|
||||
selectedModel,
|
||||
|
|
@ -2674,10 +2706,10 @@ export const LaunchTeamDialog = (props: LaunchTeamDialogProps): React.JSX.Elemen
|
|||
id="dialog-model"
|
||||
disableGeminiOption={isGeminiUiFrozen()}
|
||||
providerDisabledReasonById={{
|
||||
opencode: OPENCODE_TEAM_LEAD_DISABLED_REASON,
|
||||
opencode: OPENCODE_ONE_SHOT_DISABLED_REASON,
|
||||
}}
|
||||
providerDisabledBadgeLabelById={{
|
||||
opencode: OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL,
|
||||
opencode: OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL,
|
||||
}}
|
||||
/>
|
||||
<EffortLevelSelector
|
||||
|
|
|
|||
|
|
@ -66,9 +66,9 @@ const PROVIDERS: ProviderDef[] = [
|
|||
];
|
||||
|
||||
const OPENCODE_UI_DISABLED_REASON = 'OpenCode team launch is not ready.';
|
||||
export const OPENCODE_TEAM_LEAD_DISABLED_REASON =
|
||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.';
|
||||
export const OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL = 'side lane';
|
||||
export const OPENCODE_ONE_SHOT_DISABLED_REASON =
|
||||
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.';
|
||||
export const OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL = 'team only';
|
||||
|
||||
export function getTeamModelLabel(model: string): string {
|
||||
return getCatalogTeamModelLabel(model) ?? model;
|
||||
|
|
@ -118,9 +118,9 @@ export function formatTeamModelSummary(
|
|||
|
||||
/**
|
||||
* Computes the effective model string for team provisioning.
|
||||
* By default adds [1m] suffix for 1M context (Opus/Sonnet).
|
||||
* By default adds [1m] suffix for Opus 1M context.
|
||||
* When limitContext=true, returns base model without [1m] (200K context).
|
||||
* Haiku does not support 1M — always returned as-is.
|
||||
* Sonnet and Haiku default to standard context to avoid extra-usage-only variants.
|
||||
*/
|
||||
export function computeEffectiveTeamModel(
|
||||
selectedModel: string,
|
||||
|
|
|
|||
|
|
@ -254,7 +254,7 @@ export function analyzeTeammateRuntimeCompatibility({
|
|||
details.push(
|
||||
names
|
||||
? `OpenCode-led mixed team: ${names} use a non-OpenCode provider.`
|
||||
: 'OpenCode-led mixed teams are not supported in this phase.'
|
||||
: 'Mixed teams cannot use OpenCode as the lead in this phase.'
|
||||
);
|
||||
}
|
||||
if (hasCodexNative) {
|
||||
|
|
@ -317,7 +317,7 @@ export function analyzeTeammateRuntimeCompatibility({
|
|||
message: checking
|
||||
? 'Custom CLI args request tmux teammates. The app is checking whether tmux is available.'
|
||||
: hasOpenCodeLeadMixedUnsupported
|
||||
? 'OpenCode teammates can run as secondary runtime lanes under an Anthropic, Codex, or Gemini lead, but OpenCode-led mixed teams are not supported in this phase.'
|
||||
? 'OpenCode can be added as a teammate under an Anthropic, Codex, or Gemini lead, but mixed teams cannot use OpenCode as the lead in this phase.'
|
||||
: hasExplicitInProcess
|
||||
? 'Some teammates require separate processes. Remove --teammate-mode in-process so the app can use native process transport.'
|
||||
: 'Custom CLI args force --teammate-mode tmux, but tmux is not ready. Remove that arg to use native process transport on Windows, or install tmux/WSL tmux.',
|
||||
|
|
|
|||
|
|
@ -20,9 +20,6 @@ vi.mock('@renderer/components/team/dialogs/LimitContextCheckbox', () => ({
|
|||
vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
||||
getProviderScopedTeamModelLabel: (_providerId: string, model: string) => model || 'Default',
|
||||
getTeamProviderLabel: (providerId: string) => providerId,
|
||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'side lane',
|
||||
OPENCODE_TEAM_LEAD_DISABLED_REASON:
|
||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.',
|
||||
TeamModelSelector: () => React.createElement('div', null, 'team-model-selector'),
|
||||
}));
|
||||
|
||||
|
|
|
|||
|
|
@ -6,8 +6,6 @@ import { LimitContextCheckbox } from '@renderer/components/team/dialogs/LimitCon
|
|||
import {
|
||||
getProviderScopedTeamModelLabel,
|
||||
getTeamProviderLabel,
|
||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL,
|
||||
OPENCODE_TEAM_LEAD_DISABLED_REASON,
|
||||
TeamModelSelector,
|
||||
} from '@renderer/components/team/dialogs/TeamModelSelector';
|
||||
import { Checkbox } from '@renderer/components/ui/checkbox';
|
||||
|
|
@ -151,8 +149,6 @@ export const LeadModelRow = ({
|
|||
onValueChange={onModelChange}
|
||||
id="lead-model"
|
||||
disableGeminiOption={disableGeminiOption}
|
||||
providerDisabledReasonById={{ opencode: OPENCODE_TEAM_LEAD_DISABLED_REASON }}
|
||||
providerDisabledBadgeLabelById={{ opencode: OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL }}
|
||||
modelIssueReasonByValue={model.trim() ? { [model.trim()]: modelIssueText } : undefined}
|
||||
/>
|
||||
<EffortLevelSelector
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
buildMemberLaunchDiagnosticsPayload,
|
||||
hasMemberLaunchDiagnosticsDetails,
|
||||
hasMemberLaunchDiagnosticsError,
|
||||
normalizeMemberLaunchFailureReason,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
import { getRuntimeMemorySourceLabel } from '@renderer/utils/memberRuntimeSummary';
|
||||
import { isLeadMember } from '@shared/utils/leadDetection';
|
||||
|
|
@ -97,15 +98,6 @@ function splitRuntimeSummaryMemory(runtimeSummary: string | undefined): {
|
|||
};
|
||||
}
|
||||
|
||||
function normalizeLaunchFailureReason(value: string | undefined): string | null {
|
||||
const normalized = value
|
||||
?.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '')
|
||||
.replace(/^APIError\s*[-:]\s*/i, '');
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function getLaunchFailureLinkLabel(url: string): string {
|
||||
try {
|
||||
const parsed = new URL(url);
|
||||
|
|
@ -180,6 +172,8 @@ export const MemberCard = memo(function MemberCard({
|
|||
spawnRuntimeAlive,
|
||||
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
||||
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
||||
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
|
||||
spawnUpdatedAt: spawnEntry?.updatedAt,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
|
|
@ -197,6 +191,7 @@ export const MemberCard = memo(function MemberCard({
|
|||
const launchStatusLabel = launchPresentation.launchStatusLabel;
|
||||
const displayPresenceLabel =
|
||||
launchVisualState === 'queued' ||
|
||||
launchVisualState === 'starting_stale' ||
|
||||
launchVisualState === 'bootstrap_stalled' ||
|
||||
launchVisualState === 'runtime_pending' ||
|
||||
launchVisualState === 'permission_pending' ||
|
||||
|
|
@ -243,6 +238,7 @@ export const MemberCard = memo(function MemberCard({
|
|||
(presenceLabel === 'starting' ||
|
||||
presenceLabel === 'connecting' ||
|
||||
launchVisualState === 'queued' ||
|
||||
launchVisualState === 'starting_stale' ||
|
||||
launchVisualState === 'runtime_pending' ||
|
||||
launchVisualState === 'shell_only' ||
|
||||
launchVisualState === 'runtime_candidate' ||
|
||||
|
|
@ -289,7 +285,7 @@ export const MemberCard = memo(function MemberCard({
|
|||
spawnEntry?.runtimeDiagnostic ??
|
||||
spawnEntry?.error;
|
||||
const launchFailureReason = showFailedLaunchBadge
|
||||
? normalizeLaunchFailureReason(rawLaunchFailureReason)
|
||||
? normalizeMemberLaunchFailureReason(rawLaunchFailureReason)
|
||||
: null;
|
||||
const hasLiveLaunchControls =
|
||||
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
|
||||
|
|
@ -523,10 +519,17 @@ export const MemberCard = memo(function MemberCard({
|
|||
className="flex shrink-0 items-center gap-1"
|
||||
title={runtimeEntry?.runtimeDiagnostic}
|
||||
>
|
||||
{launchVisualState === 'starting_stale' ? (
|
||||
<AlertTriangle
|
||||
className="size-3.5 shrink-0 text-amber-400"
|
||||
aria-label={launchBadgeLabel}
|
||||
/>
|
||||
) : (
|
||||
<SyncedLoader2
|
||||
className="size-3.5 shrink-0 text-[var(--color-text-muted)]"
|
||||
aria-label={launchBadgeLabel}
|
||||
/>
|
||||
)}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="shrink-0 px-1.5 py-0.5 text-[10px] font-normal leading-none text-[var(--color-text-muted)]"
|
||||
|
|
|
|||
|
|
@ -289,6 +289,8 @@ export const MemberDetailDialog = ({
|
|||
spawnRuntimeAlive={spawnEntry?.runtimeAlive}
|
||||
spawnBootstrapConfirmed={spawnEntry?.bootstrapConfirmed}
|
||||
spawnBootstrapStalled={spawnEntry?.bootstrapStalled}
|
||||
spawnFirstSpawnAcceptedAt={spawnEntry?.firstSpawnAcceptedAt}
|
||||
spawnUpdatedAt={spawnEntry?.updatedAt}
|
||||
runtimeEntry={runtimeEntry}
|
||||
isLaunchSettling={isLaunchSettling}
|
||||
onUpdateRole={
|
||||
|
|
|
|||
|
|
@ -40,6 +40,8 @@ interface MemberDetailHeaderProps {
|
|||
spawnRuntimeAlive?: boolean;
|
||||
spawnBootstrapConfirmed?: boolean;
|
||||
spawnBootstrapStalled?: boolean;
|
||||
spawnFirstSpawnAcceptedAt?: string;
|
||||
spawnUpdatedAt?: string;
|
||||
isLaunchSettling?: boolean;
|
||||
onUpdateRole?: (newRole: string | undefined) => Promise<void> | void;
|
||||
updatingRole?: boolean;
|
||||
|
|
@ -58,6 +60,8 @@ export const MemberDetailHeader = ({
|
|||
spawnRuntimeAlive,
|
||||
spawnBootstrapConfirmed,
|
||||
spawnBootstrapStalled,
|
||||
spawnFirstSpawnAcceptedAt,
|
||||
spawnUpdatedAt,
|
||||
isLaunchSettling,
|
||||
onUpdateRole,
|
||||
updatingRole,
|
||||
|
|
@ -85,6 +89,8 @@ export const MemberDetailHeader = ({
|
|||
spawnRuntimeAlive,
|
||||
spawnBootstrapConfirmed,
|
||||
spawnBootstrapStalled,
|
||||
spawnFirstSpawnAcceptedAt,
|
||||
spawnUpdatedAt,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
|
|
@ -102,7 +108,8 @@ export const MemberDetailHeader = ({
|
|||
const badgeLabel =
|
||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||
? runtimeAdvisoryLabel
|
||||
: launchVisualState === 'bootstrap_stalled' ||
|
||||
: launchVisualState === 'starting_stale' ||
|
||||
launchVisualState === 'bootstrap_stalled' ||
|
||||
launchVisualState === 'runtime_pending' ||
|
||||
launchVisualState === 'permission_pending' ||
|
||||
launchVisualState === 'shell_only' ||
|
||||
|
|
|
|||
|
|
@ -159,6 +159,8 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
|||
spawnRuntimeAlive: spawnEntry?.runtimeAlive,
|
||||
spawnBootstrapConfirmed: spawnEntry?.bootstrapConfirmed,
|
||||
spawnBootstrapStalled: spawnEntry?.bootstrapStalled,
|
||||
spawnFirstSpawnAcceptedAt: spawnEntry?.firstSpawnAcceptedAt,
|
||||
spawnUpdatedAt: spawnEntry?.updatedAt,
|
||||
runtimeEntry,
|
||||
runtimeAdvisory: member.runtimeAdvisory,
|
||||
isLaunchSettling,
|
||||
|
|
@ -176,7 +178,8 @@ export const MemberHoverCard = memo(function MemberHoverCard({
|
|||
const badgeLabel =
|
||||
runtimeAdvisoryTone === 'error' && runtimeAdvisoryLabel
|
||||
? runtimeAdvisoryLabel
|
||||
: launchVisualState === 'bootstrap_stalled' ||
|
||||
: launchVisualState === 'starting_stale' ||
|
||||
launchVisualState === 'bootstrap_stalled' ||
|
||||
launchVisualState === 'runtime_pending' ||
|
||||
launchVisualState === 'permission_pending' ||
|
||||
launchVisualState === 'shell_only' ||
|
||||
|
|
|
|||
|
|
@ -122,6 +122,24 @@ function areTaskWorkIntervalsEquivalent(
|
|||
});
|
||||
}
|
||||
|
||||
function areTaskReviewIntervalsEquivalent(
|
||||
left: TeamTaskWithKanban['reviewIntervals'],
|
||||
right: TeamTaskWithKanban['reviewIntervals']
|
||||
): boolean {
|
||||
if (left === right) return true;
|
||||
if (!left || !right) return left === right;
|
||||
if (left.length !== right.length) return false;
|
||||
return left.every((interval, index) => {
|
||||
const other = right[index];
|
||||
if (!other) return false;
|
||||
return (
|
||||
interval.reviewer === other.reviewer &&
|
||||
interval.startedAt === other.startedAt &&
|
||||
interval.completedAt === other.completedAt
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
function areTaskHistoryEventsEquivalent(
|
||||
left: TeamTaskWithKanban['historyEvents'],
|
||||
right: TeamTaskWithKanban['historyEvents']
|
||||
|
|
@ -166,6 +184,7 @@ function areMemberTaskMapsEquivalent(
|
|||
leftTask.reviewState !== rightTask.reviewState ||
|
||||
leftTask.kanbanColumn !== rightTask.kanbanColumn ||
|
||||
!areTaskWorkIntervalsEquivalent(leftTask.workIntervals, rightTask.workIntervals) ||
|
||||
!areTaskReviewIntervalsEquivalent(leftTask.reviewIntervals, rightTask.reviewIntervals) ||
|
||||
!areTaskHistoryEventsEquivalent(leftTask.historyEvents, rightTask.historyEvents)
|
||||
) {
|
||||
return false;
|
||||
|
|
|
|||
|
|
@ -114,8 +114,7 @@ export function normalizeLeadProviderForMode(
|
|||
providerId: TeamProviderId | undefined,
|
||||
multimodelEnabled: boolean
|
||||
): TeamProviderId {
|
||||
const normalizedProviderId = normalizeProviderForMode(providerId, multimodelEnabled);
|
||||
return normalizedProviderId === 'opencode' ? 'anthropic' : normalizedProviderId;
|
||||
return normalizeProviderForMode(providerId, multimodelEnabled);
|
||||
}
|
||||
|
||||
export function normalizeMemberDraftForProviderMode(
|
||||
|
|
|
|||
|
|
@ -237,11 +237,155 @@
|
|||
animation: splash-tagline-type 1.05s steps(28, end) 0.22s forwards;
|
||||
will-change: clip-path;
|
||||
}
|
||||
#splash-status-row {
|
||||
display: flex;
|
||||
width: min(320px, 78vw);
|
||||
margin-top: 14px;
|
||||
align-items: baseline;
|
||||
justify-content: center;
|
||||
gap: 6px;
|
||||
}
|
||||
#splash-status {
|
||||
min-height: 16px;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: rgba(212, 212, 216, 0.72);
|
||||
line-height: 1.35;
|
||||
overflow-wrap: anywhere;
|
||||
}
|
||||
#splash-elapsed::before {
|
||||
content: '·';
|
||||
margin-right: 6px;
|
||||
color: rgba(212, 212, 216, 0.34);
|
||||
}
|
||||
#splash-elapsed {
|
||||
font-family:
|
||||
ui-monospace, SFMono-Regular, 'SF Mono', Menlo, Consolas, 'Liberation Mono', monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 500;
|
||||
color: rgba(212, 212, 216, 0.5);
|
||||
white-space: nowrap;
|
||||
}
|
||||
#splash-hint {
|
||||
width: min(320px, 78vw);
|
||||
min-height: 15px;
|
||||
margin-top: 6px;
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
color: rgba(212, 212, 216, 0.54);
|
||||
line-height: 1.35;
|
||||
}
|
||||
#splash-timeline {
|
||||
display: flex;
|
||||
width: min(320px, 78vw);
|
||||
max-height: 128px;
|
||||
margin-top: 14px;
|
||||
flex-direction: column;
|
||||
gap: 6px;
|
||||
overflow: hidden;
|
||||
}
|
||||
.splash-step {
|
||||
display: grid;
|
||||
grid-template-columns: 8px minmax(0, 1fr) auto;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
opacity: 0.56;
|
||||
}
|
||||
.splash-step.is-current {
|
||||
opacity: 1;
|
||||
}
|
||||
.splash-step-dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
border-radius: 999px;
|
||||
background: rgba(212, 212, 216, 0.42);
|
||||
}
|
||||
.splash-step.is-current .splash-step-dot {
|
||||
background: #a78bfa;
|
||||
box-shadow: 0 0 12px rgba(167, 139, 250, 0.55);
|
||||
}
|
||||
.splash-step-label,
|
||||
.splash-step-time {
|
||||
font-family:
|
||||
ui-sans-serif,
|
||||
system-ui,
|
||||
-apple-system,
|
||||
BlinkMacSystemFont,
|
||||
'Segoe UI',
|
||||
sans-serif;
|
||||
font-size: 11px;
|
||||
line-height: 1.25;
|
||||
}
|
||||
.splash-step-label {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: rgba(212, 212, 216, 0.62);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.splash-step.is-current .splash-step-label {
|
||||
color: rgba(244, 244, 245, 0.86);
|
||||
}
|
||||
.splash-step-time {
|
||||
color: rgba(212, 212, 216, 0.44);
|
||||
white-space: nowrap;
|
||||
}
|
||||
#splash-progress {
|
||||
position: relative;
|
||||
width: min(240px, 64vw);
|
||||
height: 3px;
|
||||
margin-top: 10px;
|
||||
overflow: hidden;
|
||||
border-radius: 999px;
|
||||
background: rgba(255, 255, 255, 0.1);
|
||||
}
|
||||
#splash-progress-bar {
|
||||
position: absolute;
|
||||
inset: 0 auto 0 0;
|
||||
width: 38%;
|
||||
border-radius: inherit;
|
||||
background: linear-gradient(90deg, #818cf8, #c084fc);
|
||||
animation: splash-progress 1.2s ease-in-out infinite;
|
||||
}
|
||||
#splash.splash-status-error #splash-status {
|
||||
color: #fca5a5;
|
||||
}
|
||||
#splash.splash-status-slow #splash-status {
|
||||
color: #fde68a;
|
||||
}
|
||||
#splash.splash-status-error #splash-progress-bar {
|
||||
background: #f87171;
|
||||
animation: none;
|
||||
}
|
||||
@keyframes splash-tagline-type {
|
||||
to {
|
||||
clip-path: inset(0 0 0 0);
|
||||
}
|
||||
}
|
||||
@keyframes splash-progress {
|
||||
0% {
|
||||
transform: translateX(-110%);
|
||||
}
|
||||
55% {
|
||||
transform: translateX(90%);
|
||||
}
|
||||
100% {
|
||||
transform: translateX(260%);
|
||||
}
|
||||
}
|
||||
|
||||
/* Logo node breathing - cycles through 3 agent nodes */
|
||||
@keyframes splash-node {
|
||||
|
|
@ -284,6 +428,26 @@
|
|||
:root.light #splash-tagline {
|
||||
color: rgba(63, 63, 70, 0.66);
|
||||
}
|
||||
:root.light #splash-status {
|
||||
color: rgba(63, 63, 70, 0.7);
|
||||
}
|
||||
:root.light #splash-elapsed,
|
||||
:root.light .splash-step-time {
|
||||
color: rgba(63, 63, 70, 0.48);
|
||||
}
|
||||
:root.light #splash-hint,
|
||||
:root.light .splash-step-label {
|
||||
color: rgba(63, 63, 70, 0.58);
|
||||
}
|
||||
:root.light .splash-step.is-current .splash-step-label {
|
||||
color: rgba(39, 39, 42, 0.82);
|
||||
}
|
||||
:root.light .splash-step-dot {
|
||||
background: rgba(63, 63, 70, 0.34);
|
||||
}
|
||||
:root.light #splash-progress {
|
||||
background: rgba(79, 70, 229, 0.14);
|
||||
}
|
||||
:root.light #splash-noise {
|
||||
opacity: 0.02;
|
||||
}
|
||||
|
|
@ -307,6 +471,7 @@
|
|||
#splash,
|
||||
#splash-enhanced-canvas,
|
||||
#splash-logo,
|
||||
#splash-progress-bar,
|
||||
#splash-tagline > span,
|
||||
.splash-node {
|
||||
animation: none !important;
|
||||
|
|
@ -446,6 +611,13 @@
|
|||
<div id="splash-copy">
|
||||
<div id="splash-text">Agent Teams AI</div>
|
||||
<div id="splash-tagline"><span>Get more done by doing less.</span></div>
|
||||
<div id="splash-status-row">
|
||||
<div id="splash-status" aria-live="polite">Preparing workspace...</div>
|
||||
<div id="splash-elapsed">0s</div>
|
||||
</div>
|
||||
<div id="splash-hint" aria-live="polite"></div>
|
||||
<div id="splash-progress" aria-hidden="true"><div id="splash-progress-bar"></div></div>
|
||||
<div id="splash-timeline" aria-label="Startup steps"></div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="root"></div>
|
||||
|
|
|
|||
|
|
@ -9,6 +9,8 @@ import { App } from './App';
|
|||
import { initSentryRenderer } from './sentry';
|
||||
import { initializeNotificationListeners } from './store';
|
||||
|
||||
import type { AppStartupStatus, AppStartupStep } from '@shared/types/api';
|
||||
|
||||
declare global {
|
||||
interface Window {
|
||||
__claudeTeamsUiDidInit?: boolean;
|
||||
|
|
@ -18,6 +20,146 @@ declare global {
|
|||
// Sentry must be initialised before React renders.
|
||||
initSentryRenderer();
|
||||
|
||||
let root: ReactDOM.Root | null = null;
|
||||
let latestStartupStatus: AppStartupStatus | null = null;
|
||||
let startupTicker: number | undefined;
|
||||
|
||||
const SLOW_STEP_MS = 7_000;
|
||||
const VERY_SLOW_STEP_MS = 14_000;
|
||||
const TIMELINE_STEP_LIMIT = 6;
|
||||
|
||||
function getStartupErrorText(status: AppStartupStatus): string {
|
||||
return status.error ? `Startup failed: ${status.error}` : 'Startup failed. Please restart.';
|
||||
}
|
||||
|
||||
function formatDuration(ms: number): string {
|
||||
const safeMs = Math.max(0, ms);
|
||||
const seconds = Math.floor(safeMs / 1000);
|
||||
if (seconds < 60) {
|
||||
return `${seconds}s`;
|
||||
}
|
||||
const minutes = Math.floor(seconds / 60);
|
||||
const rest = seconds % 60;
|
||||
return `${minutes}m ${rest.toString().padStart(2, '0')}s`;
|
||||
}
|
||||
|
||||
function getCurrentStartupStep(status: AppStartupStatus): AppStartupStep | null {
|
||||
const steps = status.steps ?? [];
|
||||
const active = [...steps].reverse().find((step) => !step.finishedAt);
|
||||
return active ?? steps[steps.length - 1] ?? null;
|
||||
}
|
||||
|
||||
function getStepElapsedMs(step: AppStartupStep | null, status: AppStartupStatus): number {
|
||||
if (!step) {
|
||||
return Date.now() - status.startedAt;
|
||||
}
|
||||
return step.finishedAt ? step.finishedAt - step.startedAt : Date.now() - step.startedAt;
|
||||
}
|
||||
|
||||
function getSlowStepHint(step: AppStartupStep | null, elapsedMs: number): string {
|
||||
if (!step || step.finishedAt || elapsedMs < SLOW_STEP_MS) {
|
||||
return '';
|
||||
}
|
||||
|
||||
const phase = step.phase;
|
||||
if (phase.includes('shell-env-login') || phase.includes('shell-env-interactive')) {
|
||||
return elapsedMs >= VERY_SLOW_STEP_MS
|
||||
? 'Shell startup is still running. Slow shell profile scripts can delay first launch.'
|
||||
: 'Reading your shell PATH. This can take a few seconds on first launch.';
|
||||
}
|
||||
if (phase.includes('node-runtime')) {
|
||||
return 'Checking Node.js for the local MCP server. This can wait up to 5 seconds.';
|
||||
}
|
||||
if (phase.includes('packaged-server-copy')) {
|
||||
return 'Preparing the packaged MCP server copy. This should only happen after updates.';
|
||||
}
|
||||
if (phase.includes('path') || phase.includes('standard-locations') || phase.includes('nvm')) {
|
||||
return 'Searching local runtime paths. A large PATH or slow disk can make this step longer.';
|
||||
}
|
||||
if (phase.includes('doctor')) {
|
||||
return 'Using diagnostics fallback to locate the runtime.';
|
||||
}
|
||||
if (phase.includes('settings')) {
|
||||
return 'Loading encrypted local settings.';
|
||||
}
|
||||
|
||||
return 'Still working on this startup step.';
|
||||
}
|
||||
|
||||
function renderStartupTimeline(status: AppStartupStatus): void {
|
||||
const timeline = document.getElementById('splash-timeline');
|
||||
if (!timeline) return;
|
||||
|
||||
const steps = (status.steps ?? []).slice(-TIMELINE_STEP_LIMIT);
|
||||
timeline.replaceChildren();
|
||||
|
||||
for (const step of steps) {
|
||||
const row = document.createElement('div');
|
||||
const isCurrent = !step.finishedAt && !status.ready && !status.error;
|
||||
row.className = `splash-step${isCurrent ? ' is-current' : ''}`;
|
||||
|
||||
const dot = document.createElement('div');
|
||||
dot.className = 'splash-step-dot';
|
||||
|
||||
const label = document.createElement('div');
|
||||
label.className = 'splash-step-label';
|
||||
label.textContent = step.message;
|
||||
label.title = step.message;
|
||||
|
||||
const time = document.createElement('div');
|
||||
time.className = 'splash-step-time';
|
||||
time.textContent = formatDuration(getStepElapsedMs(step, status));
|
||||
|
||||
row.append(dot, label, time);
|
||||
timeline.append(row);
|
||||
}
|
||||
}
|
||||
|
||||
function updateStartupSplash(status: AppStartupStatus): void {
|
||||
const splash = document.getElementById('splash');
|
||||
const statusElement = document.getElementById('splash-status');
|
||||
const elapsedElement = document.getElementById('splash-elapsed');
|
||||
const hintElement = document.getElementById('splash-hint');
|
||||
if (!splash || !statusElement) return;
|
||||
|
||||
latestStartupStatus = status;
|
||||
const currentStep = getCurrentStartupStep(status);
|
||||
const elapsedMs = getStepElapsedMs(currentStep, status);
|
||||
const hint = getSlowStepHint(currentStep, elapsedMs);
|
||||
|
||||
splash.classList.toggle('splash-status-error', Boolean(status.error) && !status.ready);
|
||||
splash.classList.toggle('splash-status-slow', Boolean(hint) && !status.error && !status.ready);
|
||||
statusElement.textContent =
|
||||
status.error && !status.ready
|
||||
? getStartupErrorText(status)
|
||||
: (currentStep?.message ?? status.message);
|
||||
if (elapsedElement) {
|
||||
elapsedElement.textContent = formatDuration(elapsedMs);
|
||||
}
|
||||
if (hintElement) {
|
||||
hintElement.textContent = status.error || status.ready ? '' : hint;
|
||||
}
|
||||
renderStartupTimeline(status);
|
||||
}
|
||||
|
||||
function startStartupTicker(): void {
|
||||
if (startupTicker !== undefined) return;
|
||||
startupTicker = window.setInterval(() => {
|
||||
if (latestStartupStatus) {
|
||||
updateStartupSplash(latestStartupStatus);
|
||||
}
|
||||
}, 1000);
|
||||
}
|
||||
|
||||
function stopStartupTicker(): void {
|
||||
if (startupTicker === undefined) return;
|
||||
window.clearInterval(startupTicker);
|
||||
startupTicker = undefined;
|
||||
}
|
||||
|
||||
function mountApp(): void {
|
||||
if (root) return;
|
||||
|
||||
// React 18 StrictMode intentionally mounts/unmounts effects twice in dev,
|
||||
// which can start duplicate IPC init chains. Make initialization a one-time
|
||||
// module-level side effect guarded by a global flag.
|
||||
|
|
@ -26,8 +168,51 @@ if (!window.__claudeTeamsUiDidInit) {
|
|||
initializeNotificationListeners();
|
||||
}
|
||||
|
||||
ReactDOM.createRoot(document.getElementById('root')!).render(
|
||||
root = ReactDOM.createRoot(document.getElementById('root')!);
|
||||
root.render(
|
||||
<React.StrictMode>
|
||||
<App />
|
||||
</React.StrictMode>
|
||||
);
|
||||
}
|
||||
|
||||
async function bootstrapRenderer(): Promise<void> {
|
||||
const startupApi = window.electronAPI?.startup;
|
||||
if (!startupApi) {
|
||||
mountApp();
|
||||
return;
|
||||
}
|
||||
|
||||
let cleanup = (): void => undefined;
|
||||
try {
|
||||
let finished = false;
|
||||
const handleStartupStatus = (nextStatus: AppStartupStatus): void => {
|
||||
if (finished) {
|
||||
return;
|
||||
}
|
||||
updateStartupSplash(nextStatus);
|
||||
if (nextStatus.ready) {
|
||||
finished = true;
|
||||
cleanup();
|
||||
stopStartupTicker();
|
||||
mountApp();
|
||||
} else if (nextStatus.error) {
|
||||
finished = true;
|
||||
cleanup();
|
||||
stopStartupTicker();
|
||||
} else {
|
||||
startStartupTicker();
|
||||
}
|
||||
};
|
||||
|
||||
cleanup = startupApi.onProgress(handleStartupStatus);
|
||||
handleStartupStatus(await startupApi.getStatus());
|
||||
} catch (error) {
|
||||
console.warn(`[startup] status bridge unavailable: ${String(error)}`);
|
||||
cleanup();
|
||||
stopStartupTicker();
|
||||
mountApp();
|
||||
}
|
||||
}
|
||||
|
||||
void bootstrapRenderer();
|
||||
|
|
|
|||
|
|
@ -2239,7 +2239,57 @@ function normalizeTeamGraphSlotAssignmentsForVisibleOwners(
|
|||
}
|
||||
normalizedAssignments[stableOwnerId] = assignment;
|
||||
}
|
||||
return normalizedAssignments;
|
||||
return normalizeLegacySixRowOrbitAssignments(normalizedAssignments, visibleOwnerIds);
|
||||
}
|
||||
|
||||
function normalizeLegacySixRowOrbitAssignments(
|
||||
assignments: TeamGraphSlotAssignments,
|
||||
visibleOwnerIds: readonly string[]
|
||||
): TeamGraphSlotAssignments {
|
||||
if (visibleOwnerIds.length !== 6) {
|
||||
return assignments;
|
||||
}
|
||||
|
||||
const visibleAssignments = visibleOwnerIds.flatMap((stableOwnerId) => {
|
||||
const assignment = assignments[stableOwnerId];
|
||||
return assignment ? [assignment] : [];
|
||||
});
|
||||
const hasLegacyTwoRowBottomMarker = visibleAssignments.some(
|
||||
(assignment) => assignment.ringIndex === 1 && assignment.sectorIndex === 2
|
||||
);
|
||||
let changed = false;
|
||||
const normalizedAssignments: TeamGraphSlotAssignments = { ...assignments };
|
||||
|
||||
for (const stableOwnerId of visibleOwnerIds) {
|
||||
const assignment = normalizedAssignments[stableOwnerId];
|
||||
if (!assignment) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
hasLegacyTwoRowBottomMarker &&
|
||||
assignment.ringIndex === 1 &&
|
||||
assignment.sectorIndex >= 0 &&
|
||||
assignment.sectorIndex < 3
|
||||
) {
|
||||
normalizedAssignments[stableOwnerId] = {
|
||||
ringIndex: 2,
|
||||
sectorIndex: assignment.sectorIndex,
|
||||
};
|
||||
changed = true;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (assignment.ringIndex === 0 && assignment.sectorIndex >= 3 && assignment.sectorIndex < 6) {
|
||||
normalizedAssignments[stableOwnerId] = {
|
||||
ringIndex: 2,
|
||||
sectorIndex: assignment.sectorIndex - 3,
|
||||
};
|
||||
changed = true;
|
||||
}
|
||||
}
|
||||
|
||||
return changed ? normalizedAssignments : assignments;
|
||||
}
|
||||
|
||||
function pruneTeamGraphSlotAssignmentsForVisibleOwners(
|
||||
|
|
|
|||
|
|
@ -332,6 +332,47 @@ export function deriveReviewActivityTimerAnchor(
|
|||
const memberKey = normalizeMemberName(params.memberName);
|
||||
if (!memberKey) return null;
|
||||
|
||||
const reviewIntervals = Array.isArray(task.reviewIntervals) ? task.reviewIntervals : [];
|
||||
for (let index = reviewIntervals.length - 1; index >= 0; index -= 1) {
|
||||
const interval = reviewIntervals[index];
|
||||
if (normalizeMemberName(interval?.reviewer) !== memberKey || interval?.completedAt) {
|
||||
continue;
|
||||
}
|
||||
const startedAtMs = parseIsoMs(interval.startedAt);
|
||||
if (startedAtMs <= 0) return null;
|
||||
|
||||
const cycleStartedAtMs = getCurrentReviewCycleStartedAtMs(task, startedAtMs);
|
||||
let baseElapsedMs = 0;
|
||||
for (let previousIndex = 0; previousIndex < index; previousIndex += 1) {
|
||||
const previous = reviewIntervals[previousIndex];
|
||||
if (normalizeMemberName(previous?.reviewer) !== memberKey) continue;
|
||||
const previousStartedAtMs = parseIsoMs(previous?.startedAt);
|
||||
const previousCompletedAtMs = parseIsoMs(previous?.completedAt);
|
||||
if (
|
||||
previousStartedAtMs >= cycleStartedAtMs &&
|
||||
previousStartedAtMs > 0 &&
|
||||
previousCompletedAtMs > previousStartedAtMs
|
||||
) {
|
||||
baseElapsedMs += previousCompletedAtMs - previousStartedAtMs;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
startedAt: interval.startedAt,
|
||||
startedAtMs,
|
||||
baseElapsedMs,
|
||||
timerId: createMemberActivityTimerId({
|
||||
teamName: params.teamName,
|
||||
memberName: params.memberName,
|
||||
phase: 'review',
|
||||
taskId: task.id,
|
||||
startedAt: interval.startedAt,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
if (reviewIntervals.length > 0) return null;
|
||||
|
||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index];
|
||||
|
|
@ -369,6 +410,27 @@ export function deriveReviewActivityTimerAnchor(
|
|||
return null;
|
||||
}
|
||||
|
||||
function getCurrentReviewCycleStartedAtMs(task: TeamTaskWithKanban, fallbackMs: number): number {
|
||||
const events = Array.isArray(task.historyEvents) ? task.historyEvents : [];
|
||||
for (let index = events.length - 1; index >= 0; index -= 1) {
|
||||
const event = events[index];
|
||||
if (event.type === 'review_started') {
|
||||
const startedAtMs = parseIsoMs(event.timestamp);
|
||||
return startedAtMs > 0 ? startedAtMs : fallbackMs;
|
||||
}
|
||||
if (
|
||||
event.type === 'review_approved' ||
|
||||
event.type === 'review_changes_requested' ||
|
||||
event.type === 'task_created' ||
|
||||
(event.type === 'status_changed' &&
|
||||
(event.to === 'in_progress' || event.to === 'pending' || event.to === 'deleted'))
|
||||
) {
|
||||
return fallbackMs;
|
||||
}
|
||||
}
|
||||
return fallbackMs;
|
||||
}
|
||||
|
||||
export function resetMemberActivityTimerStoreForTests(): void {
|
||||
timers.clear();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -133,6 +133,7 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
|||
};
|
||||
|
||||
const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 60 * 1000;
|
||||
export const MEMBER_STARTING_STALE_AFTER_MS = 2 * 60 * 1000;
|
||||
|
||||
function isLaunchStillStarting(
|
||||
spawnStatus: MemberSpawnStatus | undefined,
|
||||
|
|
@ -634,6 +635,7 @@ export type MemberLaunchVisualState =
|
|||
| 'queued'
|
||||
| 'waiting'
|
||||
| 'spawning'
|
||||
| 'starting_stale'
|
||||
| 'permission_pending'
|
||||
| 'bootstrap_stalled'
|
||||
| 'runtime_pending'
|
||||
|
|
@ -666,6 +668,8 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
|
|||
return 'waiting to start';
|
||||
case 'spawning':
|
||||
return 'starting';
|
||||
case 'starting_stale':
|
||||
return 'starting stale';
|
||||
case 'permission_pending':
|
||||
return 'awaiting permission';
|
||||
case 'bootstrap_stalled':
|
||||
|
|
@ -700,6 +704,8 @@ function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): str
|
|||
case 'runtime_pending':
|
||||
case 'runtime_candidate':
|
||||
return 'bg-amber-400 animate-pulse';
|
||||
case 'starting_stale':
|
||||
return 'bg-amber-400';
|
||||
case 'registered_only':
|
||||
return SPAWN_DOT_COLORS.waiting;
|
||||
case 'shell_only':
|
||||
|
|
@ -794,6 +800,41 @@ function hasElapsedSinceIso(
|
|||
return Number.isFinite(parsed) && nowMs - parsed >= thresholdMs;
|
||||
}
|
||||
|
||||
export function isMemberStartingStale({
|
||||
spawnStatus,
|
||||
spawnLaunchState,
|
||||
spawnFirstSpawnAcceptedAt,
|
||||
spawnUpdatedAt,
|
||||
nowMs = Date.now(),
|
||||
}: {
|
||||
spawnStatus?: MemberSpawnStatus;
|
||||
spawnLaunchState?: MemberLaunchState;
|
||||
spawnFirstSpawnAcceptedAt?: string;
|
||||
spawnUpdatedAt?: string;
|
||||
nowMs?: number;
|
||||
}): boolean {
|
||||
if (
|
||||
spawnLaunchState === 'failed_to_start' ||
|
||||
spawnLaunchState === 'confirmed_alive' ||
|
||||
spawnLaunchState === 'skipped_for_launch' ||
|
||||
spawnLaunchState === 'runtime_pending_permission' ||
|
||||
spawnStatus === 'error' ||
|
||||
spawnStatus === 'online' ||
|
||||
spawnStatus === 'skipped'
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
if (spawnLaunchState !== 'starting' && spawnStatus !== 'waiting' && spawnStatus !== 'spawning') {
|
||||
return false;
|
||||
}
|
||||
|
||||
return hasElapsedSinceIso(
|
||||
spawnFirstSpawnAcceptedAt ?? spawnUpdatedAt,
|
||||
MEMBER_STARTING_STALE_AFTER_MS,
|
||||
nowMs
|
||||
);
|
||||
}
|
||||
|
||||
function hasBootstrapStallDiagnostic(value: string | undefined): boolean {
|
||||
const normalized = value?.trim().toLowerCase() ?? '';
|
||||
return (
|
||||
|
|
@ -881,12 +922,15 @@ export function buildMemberLaunchPresentation({
|
|||
spawnRuntimeAlive,
|
||||
spawnBootstrapConfirmed,
|
||||
spawnBootstrapStalled,
|
||||
spawnFirstSpawnAcceptedAt,
|
||||
spawnUpdatedAt,
|
||||
runtimeAdvisory,
|
||||
runtimeEntry,
|
||||
isLaunchSettling = false,
|
||||
isTeamAlive,
|
||||
isTeamProvisioning,
|
||||
leadActivity,
|
||||
nowMs,
|
||||
}: {
|
||||
member: ResolvedTeamMember;
|
||||
spawnStatus: MemberSpawnStatus | undefined;
|
||||
|
|
@ -895,12 +939,15 @@ export function buildMemberLaunchPresentation({
|
|||
spawnRuntimeAlive: boolean | undefined;
|
||||
spawnBootstrapConfirmed?: boolean;
|
||||
spawnBootstrapStalled?: boolean;
|
||||
spawnFirstSpawnAcceptedAt?: string;
|
||||
spawnUpdatedAt?: string;
|
||||
runtimeAdvisory: MemberRuntimeAdvisory | undefined;
|
||||
runtimeEntry?: TeamAgentRuntimeEntry;
|
||||
isLaunchSettling?: boolean;
|
||||
isTeamAlive?: boolean;
|
||||
isTeamProvisioning?: boolean;
|
||||
leadActivity?: LeadActivityState;
|
||||
nowMs?: number;
|
||||
}): MemberLaunchPresentation {
|
||||
const hasConfirmedSpawnLaunch =
|
||||
spawnLaunchState === 'confirmed_alive' && spawnBootstrapConfirmed === true;
|
||||
|
|
@ -943,6 +990,15 @@ export function buildMemberLaunchPresentation({
|
|||
const runtimeAdvisoryTitle = getMemberRuntimeAdvisoryTitle(runtimeAdvisory, member.providerId);
|
||||
const runtimeAdvisoryTone = getMemberRuntimeAdvisoryTone(runtimeAdvisory);
|
||||
const keepLaunchSettlingVisuals = isTeamProvisioning === true || isLaunchSettling;
|
||||
const startingIsStale =
|
||||
!hasConfirmedSpawnLaunch &&
|
||||
isMemberStartingStale({
|
||||
spawnStatus,
|
||||
spawnLaunchState,
|
||||
spawnFirstSpawnAcceptedAt,
|
||||
spawnUpdatedAt,
|
||||
nowMs,
|
||||
});
|
||||
|
||||
let launchVisualState: MemberLaunchVisualState = null;
|
||||
if (isTeamAlive !== false || isTeamProvisioning) {
|
||||
|
|
@ -969,6 +1025,8 @@ export function buildMemberLaunchPresentation({
|
|||
runtimeEntry?.livenessKind === 'not_found')
|
||||
) {
|
||||
launchVisualState = 'stale_runtime';
|
||||
} else if (!hasConfirmedSpawnLaunch && startingIsStale) {
|
||||
launchVisualState = 'starting_stale';
|
||||
} else if (
|
||||
!hasConfirmedSpawnLaunch &&
|
||||
isQueuedOpenCodeLaunch(
|
||||
|
|
@ -1007,6 +1065,7 @@ export function buildMemberLaunchPresentation({
|
|||
const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState);
|
||||
const shouldShowLaunchStatusAsPresence =
|
||||
launchVisualState === 'queued' ||
|
||||
launchVisualState === 'starting_stale' ||
|
||||
launchVisualState === 'permission_pending' ||
|
||||
launchVisualState === 'bootstrap_stalled' ||
|
||||
launchVisualState === 'runtime_pending' ||
|
||||
|
|
@ -1023,7 +1082,9 @@ export function buildMemberLaunchPresentation({
|
|||
const spawnBadgeLabel =
|
||||
spawnStatus && spawnStatus !== 'online'
|
||||
? spawnStatus === 'waiting' || spawnStatus === 'spawning'
|
||||
? 'starting'
|
||||
? startingIsStale
|
||||
? 'starting stale'
|
||||
: 'starting'
|
||||
: spawnStatus
|
||||
: null;
|
||||
|
||||
|
|
@ -1033,7 +1094,7 @@ export function buildMemberLaunchPresentation({
|
|||
runtimeAdvisoryTone === 'error'
|
||||
? STATUS_DOT_COLORS.terminated
|
||||
: (launchVisualStateDotClass ?? baseDotClass),
|
||||
cardClass,
|
||||
cardClass: launchVisualState === 'starting_stale' ? 'opacity-90' : cardClass,
|
||||
runtimeAdvisoryLabel,
|
||||
runtimeAdvisoryTitle,
|
||||
runtimeAdvisoryTone,
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ export interface MemberLaunchDiagnosticsPayload {
|
|||
teamName?: string;
|
||||
runId?: string;
|
||||
memberName: string;
|
||||
memberCardError?: string;
|
||||
launchState?: MemberLaunchState;
|
||||
spawnStatus?: MemberSpawnStatus;
|
||||
livenessKind?: TeamAgentRuntimeLivenessKind;
|
||||
|
|
@ -55,6 +56,15 @@ function boundedNumber(value: number | undefined): number | undefined {
|
|||
: undefined;
|
||||
}
|
||||
|
||||
export function normalizeMemberLaunchFailureReason(value: string | undefined): string | null {
|
||||
const normalized = value
|
||||
?.replace(/\s+/g, ' ')
|
||||
.trim()
|
||||
.replace(/^Latest assistant message\s+\S+\s+failed with APIError\s*[-:]\s*/i, '')
|
||||
.replace(/^APIError\s*[-:]\s*/i, '');
|
||||
return normalized && normalized.length > 0 ? normalized : null;
|
||||
}
|
||||
|
||||
function uniqueDiagnostics(
|
||||
...groups: (readonly (string | undefined)[] | undefined)[]
|
||||
): string[] | undefined {
|
||||
|
|
@ -91,7 +101,16 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
|
|||
boundedString(runtimeEntry?.runtimeDiagnostic) ??
|
||||
boundedString(spawnEntry?.hardFailureReason) ??
|
||||
boundedString(spawnEntry?.error);
|
||||
const memberCardError = boundedString(
|
||||
normalizeMemberLaunchFailureReason(
|
||||
spawnEntry?.error ??
|
||||
spawnEntry?.hardFailureReason ??
|
||||
spawnEntry?.runtimeDiagnostic ??
|
||||
runtimeEntry?.runtimeDiagnostic
|
||||
) ?? undefined
|
||||
);
|
||||
const diagnostics = uniqueDiagnostics(
|
||||
memberCardError ? [memberCardError] : undefined,
|
||||
runtimeDiagnostic ? [runtimeDiagnostic] : undefined,
|
||||
spawnEntry?.hardFailureReason ? [spawnEntry.hardFailureReason] : undefined,
|
||||
spawnEntry?.error ? [spawnEntry.error] : undefined,
|
||||
|
|
@ -103,6 +122,7 @@ export function buildMemberLaunchDiagnosticsPayload(params: {
|
|||
...(params.teamName ? { teamName: params.teamName } : {}),
|
||||
...(runId ? { runId } : {}),
|
||||
memberName: params.memberName,
|
||||
...(memberCardError ? { memberCardError } : {}),
|
||||
...((spawnEntry?.launchState ?? params.launchState)
|
||||
? { launchState: spawnEntry?.launchState ?? params.launchState }
|
||||
: {}),
|
||||
|
|
@ -161,6 +181,7 @@ export function hasMemberLaunchDiagnosticsDetails(
|
|||
return Boolean(
|
||||
(payload.launchState && payload.launchState !== 'confirmed_alive') ||
|
||||
(payload.spawnStatus && payload.spawnStatus !== 'online') ||
|
||||
payload.memberCardError ||
|
||||
payload.bootstrapStalled === true ||
|
||||
weakLiveness ||
|
||||
payload.runtimeDiagnostic ||
|
||||
|
|
@ -182,7 +203,12 @@ export function getMemberLaunchDiagnosticsErrorMessage(
|
|||
if (!hasMemberLaunchDiagnosticsError(payload)) {
|
||||
return undefined;
|
||||
}
|
||||
return payload.runtimeDiagnostic ?? payload.diagnostics?.[0] ?? 'Launch failed';
|
||||
return (
|
||||
payload.memberCardError ??
|
||||
payload.runtimeDiagnostic ??
|
||||
payload.diagnostics?.[0] ??
|
||||
'Launch failed'
|
||||
);
|
||||
}
|
||||
|
||||
export function formatMemberLaunchDiagnosticsPayload(
|
||||
|
|
|
|||
|
|
@ -319,6 +319,34 @@ export interface UpdaterAPI {
|
|||
onStatus: (callback: (event: unknown, status: unknown) => void) => () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Startup API
|
||||
// =============================================================================
|
||||
|
||||
export interface AppStartupStatus {
|
||||
phase: string;
|
||||
message: string;
|
||||
ready: boolean;
|
||||
error?: string | null;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
steps?: AppStartupStep[];
|
||||
}
|
||||
|
||||
export interface AppStartupStep {
|
||||
phase: string;
|
||||
message: string;
|
||||
startedAt: number;
|
||||
updatedAt: number;
|
||||
finishedAt?: number;
|
||||
durationMs?: number;
|
||||
}
|
||||
|
||||
export interface AppStartupAPI {
|
||||
getStatus: () => Promise<AppStartupStatus>;
|
||||
onProgress: (callback: (status: AppStartupStatus) => void) => () => void;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// Context API
|
||||
// =============================================================================
|
||||
|
|
@ -770,6 +798,7 @@ export interface ReviewAPI {
|
|||
* Complete Electron API exposed to the renderer process via preload script.
|
||||
*/
|
||||
export interface ElectronAPI extends RecentProjectsElectronApi, CodexAccountElectronApi {
|
||||
startup?: AppStartupAPI;
|
||||
getAppVersion: () => Promise<string>;
|
||||
getProjects: () => Promise<Project[]>;
|
||||
getSessions: (projectId: string) => Promise<Session[]>;
|
||||
|
|
|
|||
|
|
@ -105,6 +105,15 @@ export interface TaskWorkInterval {
|
|||
completedAt?: string;
|
||||
}
|
||||
|
||||
export interface TaskReviewInterval {
|
||||
/** Reviewer actively reviewing during this interval. */
|
||||
reviewer: string;
|
||||
/** ISO timestamp when reviewer started or resumed review. */
|
||||
startedAt: string;
|
||||
/** ISO timestamp when reviewer stopped, paused, approved, or requested changes. */
|
||||
completedAt?: string;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Task History Events — unified workflow event log
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -470,6 +479,10 @@ export interface TeamTask {
|
|||
* We persist intervals for reliable log attribution without relying on heuristics.
|
||||
*/
|
||||
workIntervals?: TaskWorkInterval[];
|
||||
/**
|
||||
* Review work periods, split across runtime pauses/restarts just like workIntervals.
|
||||
*/
|
||||
reviewIntervals?: TaskReviewInterval[];
|
||||
/**
|
||||
* Unified workflow event log.
|
||||
* Append-only — records task creation, status changes, and review transitions.
|
||||
|
|
|
|||
|
|
@ -10,6 +10,16 @@ function isAnthropicHaikuModel(model: string): boolean {
|
|||
return baseModel === 'haiku' || baseModel.startsWith('claude-haiku-');
|
||||
}
|
||||
|
||||
function isAnthropicSonnetModel(model: string): boolean {
|
||||
const baseModel = stripOneMillionSuffix(model);
|
||||
return baseModel === 'sonnet' || baseModel.startsWith('claude-sonnet-');
|
||||
}
|
||||
|
||||
function normalizeStandardOnlyAnthropicModel(model: string): string {
|
||||
const baseModel = stripOneMillionSuffix(model);
|
||||
return isAnthropicHaikuModel(baseModel) || isAnthropicSonnetModel(baseModel) ? baseModel : model;
|
||||
}
|
||||
|
||||
function normalizeAvailableLaunchModels(
|
||||
availableLaunchModels: Iterable<string> | undefined
|
||||
): Set<string> {
|
||||
|
|
@ -52,9 +62,10 @@ export function resolveAnthropicLaunchModel(params: {
|
|||
if (!selectedModel || isDefaultProviderModelSelection(selectedModel)) {
|
||||
const staticDefault = getAnthropicDefaultTeamModel(params.limitContext);
|
||||
const runtimeDefault = params.defaultLaunchModel?.trim() || null;
|
||||
const rawPreferredDefault = runtimeDefault || staticDefault;
|
||||
const preferredDefault = params.limitContext
|
||||
? stripOneMillionSuffix(runtimeDefault || staticDefault) || staticDefault
|
||||
: runtimeDefault || staticDefault;
|
||||
? stripOneMillionSuffix(rawPreferredDefault) || staticDefault
|
||||
: normalizeStandardOnlyAnthropicModel(rawPreferredDefault) || staticDefault;
|
||||
if (availableModels.size === 0) {
|
||||
return preferredDefault;
|
||||
}
|
||||
|
|
@ -74,7 +85,11 @@ export function resolveAnthropicLaunchModel(params: {
|
|||
return null;
|
||||
}
|
||||
|
||||
if (params.limitContext || isAnthropicHaikuModel(baseModel)) {
|
||||
if (
|
||||
params.limitContext ||
|
||||
isAnthropicHaikuModel(baseModel) ||
|
||||
isAnthropicSonnetModel(baseModel)
|
||||
) {
|
||||
return baseModel;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -33,6 +33,90 @@ const SMALL_TEAM_CARDINAL_SLOT_PRESETS: readonly (readonly GraphOwnerSlotAssignm
|
|||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 0, sectorIndex: 3 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 0, sectorIndex: 4 },
|
||||
{ ringIndex: 0, sectorIndex: 5 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 2, sectorIndex: 0 },
|
||||
{ ringIndex: 2, sectorIndex: 1 },
|
||||
{ ringIndex: 2, sectorIndex: 2 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 1, sectorIndex: 0 },
|
||||
{ ringIndex: 1, sectorIndex: 1 },
|
||||
{ ringIndex: 2, sectorIndex: 0 },
|
||||
{ ringIndex: 2, sectorIndex: 1 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 1, sectorIndex: 0 },
|
||||
{ ringIndex: 1, sectorIndex: 1 },
|
||||
{ ringIndex: 2, sectorIndex: 0 },
|
||||
{ ringIndex: 2, sectorIndex: 1 },
|
||||
{ ringIndex: 2, sectorIndex: 2 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 1, sectorIndex: 0 },
|
||||
{ ringIndex: 1, sectorIndex: 1 },
|
||||
{ ringIndex: 2, sectorIndex: 0 },
|
||||
{ ringIndex: 2, sectorIndex: 1 },
|
||||
{ ringIndex: 3, sectorIndex: 0 },
|
||||
{ ringIndex: 3, sectorIndex: 1 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 1, sectorIndex: 0 },
|
||||
{ ringIndex: 1, sectorIndex: 1 },
|
||||
{ ringIndex: 2, sectorIndex: 0 },
|
||||
{ ringIndex: 2, sectorIndex: 1 },
|
||||
{ ringIndex: 3, sectorIndex: 0 },
|
||||
{ ringIndex: 3, sectorIndex: 1 },
|
||||
{ ringIndex: 3, sectorIndex: 2 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 1, sectorIndex: 0 },
|
||||
{ ringIndex: 1, sectorIndex: 1 },
|
||||
{ ringIndex: 1, sectorIndex: 2 },
|
||||
{ ringIndex: 2, sectorIndex: 0 },
|
||||
{ ringIndex: 2, sectorIndex: 1 },
|
||||
{ ringIndex: 3, sectorIndex: 0 },
|
||||
{ ringIndex: 3, sectorIndex: 1 },
|
||||
{ ringIndex: 3, sectorIndex: 2 },
|
||||
],
|
||||
[
|
||||
{ ringIndex: 0, sectorIndex: 0 },
|
||||
{ ringIndex: 0, sectorIndex: 1 },
|
||||
{ ringIndex: 0, sectorIndex: 2 },
|
||||
{ ringIndex: 1, sectorIndex: 0 },
|
||||
{ ringIndex: 1, sectorIndex: 1 },
|
||||
{ ringIndex: 1, sectorIndex: 2 },
|
||||
{ ringIndex: 2, sectorIndex: 0 },
|
||||
{ ringIndex: 2, sectorIndex: 1 },
|
||||
{ ringIndex: 2, sectorIndex: 2 },
|
||||
{ ringIndex: 3, sectorIndex: 0 },
|
||||
{ ringIndex: 3, sectorIndex: 1 },
|
||||
{ ringIndex: 3, sectorIndex: 2 },
|
||||
],
|
||||
];
|
||||
|
||||
export function buildOrderedVisibleTeamGraphOwnerIds(
|
||||
|
|
|
|||
|
|
@ -0,0 +1,268 @@
|
|||
{
|
||||
"generatedAt": "2026-05-08T18:34:37.950Z",
|
||||
"runsPerModel": 1,
|
||||
"qualification": {
|
||||
"minimumAverageScore": 80,
|
||||
"minimumSuccessfulRuns": 1,
|
||||
"minimumConsistencyScore": 85,
|
||||
"requireNoHardFailures": true
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"model": "opencode/big-pickle",
|
||||
"verdict": "tested-only",
|
||||
"confidence": "low",
|
||||
"qualified": false,
|
||||
"readinessScore": 73,
|
||||
"averageScore": 90,
|
||||
"consistencyScore": 100,
|
||||
"behavioralAverageScore": 90,
|
||||
"minScore": 90,
|
||||
"successfulRuns": 0,
|
||||
"countedRuns": 1,
|
||||
"hardFailures": 1,
|
||||
"providerInfraFailures": 0,
|
||||
"runtimeTransportFailures": 0,
|
||||
"modelBehaviorFailures": 1,
|
||||
"harnessFailures": 0,
|
||||
"p50DurationMs": 124249,
|
||||
"p95DurationMs": 124249,
|
||||
"stagePassRates": {
|
||||
"launchBootstrap": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
},
|
||||
"directReply": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
},
|
||||
"peerRelayAB": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
},
|
||||
"peerRelayBC": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
},
|
||||
"concurrentReplies": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
},
|
||||
"taskRefs": {
|
||||
"passed": 0,
|
||||
"total": 1,
|
||||
"rate": 0
|
||||
},
|
||||
"cleanTranscript": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
},
|
||||
"noDuplicateTokens": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
},
|
||||
"latencyStable": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
}
|
||||
},
|
||||
"taskRefPassRates": {
|
||||
"directReply": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
},
|
||||
"peerRelayAB": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
},
|
||||
"peerRelayBC": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
},
|
||||
"concurrentBob": {
|
||||
"passed": 0,
|
||||
"total": 1,
|
||||
"rate": 0
|
||||
},
|
||||
"concurrentTom": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
}
|
||||
},
|
||||
"protocolViolationTotals": {
|
||||
"badMessages": 0,
|
||||
"duplicateOrMissingTokens": 0,
|
||||
"affectedRuns": 0
|
||||
},
|
||||
"stageFailureImpact": [
|
||||
{
|
||||
"stage": "taskRefs",
|
||||
"failedRuns": 1,
|
||||
"weightedLoss": 10,
|
||||
"passRate": {
|
||||
"passed": 0,
|
||||
"total": 1,
|
||||
"rate": 0
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage": "cleanTranscript",
|
||||
"failedRuns": 0,
|
||||
"weightedLoss": 0,
|
||||
"passRate": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage": "concurrentReplies",
|
||||
"failedRuns": 0,
|
||||
"weightedLoss": 0,
|
||||
"passRate": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage": "directReply",
|
||||
"failedRuns": 0,
|
||||
"weightedLoss": 0,
|
||||
"passRate": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage": "latencyStable",
|
||||
"failedRuns": 0,
|
||||
"weightedLoss": 0,
|
||||
"passRate": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage": "launchBootstrap",
|
||||
"failedRuns": 0,
|
||||
"weightedLoss": 0,
|
||||
"passRate": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage": "noDuplicateTokens",
|
||||
"failedRuns": 0,
|
||||
"weightedLoss": 0,
|
||||
"passRate": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage": "peerRelayAB",
|
||||
"failedRuns": 0,
|
||||
"weightedLoss": 0,
|
||||
"passRate": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
}
|
||||
},
|
||||
{
|
||||
"stage": "peerRelayBC",
|
||||
"failedRuns": 0,
|
||||
"weightedLoss": 0,
|
||||
"passRate": {
|
||||
"passed": 1,
|
||||
"total": 1,
|
||||
"rate": 100
|
||||
}
|
||||
}
|
||||
],
|
||||
"scoreStability": {
|
||||
"sampleSize": 1,
|
||||
"minScore": 90,
|
||||
"maxScore": 90,
|
||||
"spread": 0,
|
||||
"standardDeviation": 0,
|
||||
"consistencyScore": 100
|
||||
},
|
||||
"dominantFailureCategory": "model-behavior",
|
||||
"recommendationBlockers": [
|
||||
"successful runs 0 < 1",
|
||||
"hard failures 1",
|
||||
"model-behavior failures 1",
|
||||
"highest weighted stage loss taskRefs=10",
|
||||
"weakest taskRefs concurrentBob=0/1 (0%)"
|
||||
],
|
||||
"runs": [
|
||||
{
|
||||
"runIndex": 1,
|
||||
"passed": false,
|
||||
"score": 90,
|
||||
"countedForRecommendation": true,
|
||||
"outcome": "behavioral-fail",
|
||||
"failureCategory": "model-behavior",
|
||||
"primaryFailure": null,
|
||||
"durationMs": 124249,
|
||||
"hardFailure": true,
|
||||
"stageDurationsMs": {
|
||||
"setup": 214,
|
||||
"launchBootstrap": 23875,
|
||||
"materializeTasks": 32,
|
||||
"directReply": 11617,
|
||||
"peerRelayAB": 27950,
|
||||
"peerRelayBC": 25689,
|
||||
"concurrentReplies": 25243,
|
||||
"hygiene": 1
|
||||
},
|
||||
"stageFailures": {},
|
||||
"taskRefChecks": {
|
||||
"directReply": true,
|
||||
"peerRelayAB": true,
|
||||
"peerRelayBC": true,
|
||||
"concurrentBob": false,
|
||||
"concurrentTom": true
|
||||
},
|
||||
"protocolViolations": {
|
||||
"badMessages": 0,
|
||||
"duplicateOrMissingTokens": []
|
||||
},
|
||||
"stages": {
|
||||
"launchBootstrap": true,
|
||||
"directReply": true,
|
||||
"peerRelayAB": true,
|
||||
"peerRelayBC": true,
|
||||
"concurrentReplies": true,
|
||||
"taskRefs": false,
|
||||
"cleanTranscript": true,
|
||||
"noDuplicateTokens": true,
|
||||
"latencyStable": true
|
||||
},
|
||||
"diagnostics": [
|
||||
"runId=34e07fb0-df87-4419-be0c-0f5386847b23"
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,37 @@
|
|||
# OpenCode Model Gauntlet Results
|
||||
|
||||
Generated: 2026-05-08T18:34:37.950Z
|
||||
|
||||
Runs per model: 1
|
||||
Recommended threshold: average >= 80, successful runs >= 1, consistency >= 85, hard failures = 0
|
||||
|
||||
Provider-infra runs are reported separately and are not counted as model behavior. They still block a Recommended verdict until rerun succeeds.
|
||||
|
||||
Scoring weights: launchBootstrap=15, directReply=10, peerRelayAB=15, peerRelayBC=15, concurrentReplies=15, taskRefs=10, cleanTranscript=10, noDuplicateTokens=5, latencyStable=5.
|
||||
|
||||
## Model Summary
|
||||
|
||||
| Model | Verdict | Confidence | Readiness | Consistency | Score Spread | Behavior Avg | Overall Avg | Counted | Pass Runs | Weakest Stage | Weakest TaskRef | Dominant Failure | Blockers | Provider Infra | Runtime Transport | Model Fails | Protocol Runs | p50 | p95 |
|
||||
| --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: | ---: | --- | --- | --- | --- | ---: | ---: | ---: | ---: | ---: | ---: |
|
||||
| `opencode/big-pickle` | Tested only | low | 73 | 100 | 0 | 90 | 90 | 1/1 | 0/1 | taskRefs 0/1 (0%) | concurrentBob 0/1 (0%) | model-behavior | successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%) | 0 | 0 | 1 | 0 | 124249ms | 124249ms |
|
||||
|
||||
## opencode/big-pickle
|
||||
|
||||
Readiness score: 73.
|
||||
|
||||
Score stability: consistency=100, min=90, max=90, spread=0, stdDev=0, samples=1.
|
||||
|
||||
Recommendation blockers: successful runs 0 < 1; hard failures 1; model-behavior failures 1; highest weighted stage loss taskRefs=10; weakest taskRefs concurrentBob=0/1 (0%).
|
||||
|
||||
Weighted stage impact: taskRefs:loss=10, failed=1, pass=0/1 (0%).
|
||||
|
||||
Stage pass rates: launchBootstrap:1/1 (100%), directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentReplies:1/1 (100%), taskRefs:0/1 (0%), cleanTranscript:1/1 (100%), noDuplicateTokens:1/1 (100%), latencyStable:1/1 (100%).
|
||||
|
||||
TaskRef pass rates: directReply:1/1 (100%), peerRelayAB:1/1 (100%), peerRelayBC:1/1 (100%), concurrentBob:0/1 (0%), concurrentTom:1/1 (100%).
|
||||
|
||||
Protocol totals: badMessages=0, duplicateOrMissingTokens=0, affectedRuns=0.
|
||||
|
||||
| Run | Outcome | Category | Score | Counted | Duration | Failed Stages | Slowest Stage | TaskRefs | Protocol | Diagnostics |
|
||||
| ---: | --- | --- | ---: | --- | ---: | --- | --- | --- | --- | --- |
|
||||
| 1 | behavioral-fail | model-behavior | 90 | yes | 124249ms | taskRefs | peerRelayAB:27950ms | directReply:ok, peerRelayAB:ok, peerRelayBC:ok, concurrentBob:fail, concurrentTom:ok | - | runId=34e07fb0-df87-4419-be0c-0f5386847b23 |
|
||||
|
||||
271
test/agent-graph/stableSlots.test.ts
Normal file
271
test/agent-graph/stableSlots.test.ts
Normal file
|
|
@ -0,0 +1,271 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import {
|
||||
buildStableSlotLayoutSnapshot,
|
||||
resolveNearestSlotAssignment,
|
||||
type StableRect,
|
||||
type StableSlotLayoutSnapshot,
|
||||
validateStableSlotLayout,
|
||||
} from '../../packages/agent-graph/src/layout/stableSlots';
|
||||
|
||||
import type {
|
||||
GraphLayoutPort,
|
||||
GraphNode,
|
||||
GraphOwnerSlotAssignment,
|
||||
} from '../../packages/agent-graph/src/ports/types';
|
||||
|
||||
function ownerNode(id: string, kind: 'lead' | 'member' = 'member'): GraphNode {
|
||||
return {
|
||||
id,
|
||||
kind,
|
||||
label: id,
|
||||
state: 'idle',
|
||||
domainRef: {
|
||||
kind,
|
||||
teamName: 'test-team',
|
||||
memberName: id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function taskNode(id: string, ownerId: string, index: number): GraphNode {
|
||||
return {
|
||||
id,
|
||||
kind: 'task',
|
||||
label: id,
|
||||
state: 'idle',
|
||||
ownerId,
|
||||
taskStatus: index === 0 ? 'in_progress' : 'pending',
|
||||
reviewState: index === 1 ? 'review' : 'none',
|
||||
domainRef: {
|
||||
kind: 'task',
|
||||
teamName: 'test-team',
|
||||
taskId: id,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildOwnerGraph(
|
||||
ownerCount: number,
|
||||
slotAssignments: Record<string, GraphOwnerSlotAssignment>
|
||||
): { nodes: GraphNode[]; layout: GraphLayoutPort } {
|
||||
const nodes: GraphNode[] = [ownerNode('lead', 'lead')];
|
||||
const ownerOrder = Array.from({ length: ownerCount }, (_, index) => `member-${index}`);
|
||||
|
||||
ownerOrder.forEach((ownerId, ownerIndex) => {
|
||||
nodes.push(ownerNode(ownerId));
|
||||
|
||||
for (let taskIndex = 0; taskIndex < 3; taskIndex += 1) {
|
||||
nodes.push(taskNode(`task-${ownerIndex}-${taskIndex}`, ownerId, taskIndex));
|
||||
}
|
||||
});
|
||||
|
||||
return {
|
||||
nodes,
|
||||
layout: {
|
||||
version: 'stable-slots-v1',
|
||||
mode: 'radial',
|
||||
ownerOrder,
|
||||
slotAssignments,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function buildSixOwnerGraph(): { nodes: GraphNode[]; layout: GraphLayoutPort } {
|
||||
return buildOwnerGraph(
|
||||
6,
|
||||
Object.fromEntries(
|
||||
Array.from({ length: 6 }, (_, index) => [
|
||||
`member-${index}`,
|
||||
{ ringIndex: 0, sectorIndex: index },
|
||||
])
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
function buildRowOrbitGraph(
|
||||
ownerCount: number,
|
||||
rowCounts: readonly number[]
|
||||
): {
|
||||
nodes: GraphNode[];
|
||||
layout: GraphLayoutPort;
|
||||
} {
|
||||
const assignments: Record<string, GraphOwnerSlotAssignment> = {};
|
||||
let ownerIndex = 0;
|
||||
rowCounts.forEach((columnCount, ringIndex) => {
|
||||
for (let sectorIndex = 0; sectorIndex < columnCount; sectorIndex += 1) {
|
||||
assignments[`member-${ownerIndex}`] = { ringIndex, sectorIndex };
|
||||
ownerIndex += 1;
|
||||
}
|
||||
});
|
||||
return buildOwnerGraph(ownerCount, assignments);
|
||||
}
|
||||
|
||||
function getSnapshot(nodes: GraphNode[], layout: GraphLayoutPort): StableSlotLayoutSnapshot {
|
||||
const snapshot = buildStableSlotLayoutSnapshot({
|
||||
teamName: 'test-team',
|
||||
nodes,
|
||||
layout,
|
||||
});
|
||||
expect(snapshot).not.toBeNull();
|
||||
expect(validateStableSlotLayout(snapshot!)).toEqual({ valid: true });
|
||||
return snapshot!;
|
||||
}
|
||||
|
||||
function rectsOverlap(left: StableRect, right: StableRect): boolean {
|
||||
return (
|
||||
left.left < right.right &&
|
||||
left.right > right.left &&
|
||||
left.top < right.bottom &&
|
||||
left.bottom > right.top
|
||||
);
|
||||
}
|
||||
|
||||
function getRowCounts(snapshot: StableSlotLayoutSnapshot): number[] {
|
||||
const rowCounts = new Map<number, number>();
|
||||
for (const frame of snapshot.memberSlotFrames) {
|
||||
rowCounts.set(frame.ringIndex, (rowCounts.get(frame.ringIndex) ?? 0) + 1);
|
||||
}
|
||||
return Array.from(rowCounts.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, count]) => count);
|
||||
}
|
||||
|
||||
function getRowWidths(snapshot: StableSlotLayoutSnapshot): number[] {
|
||||
const rows = new Map<number, StableRect[]>();
|
||||
for (const frame of snapshot.memberSlotFrames) {
|
||||
rows.set(frame.ringIndex, [...(rows.get(frame.ringIndex) ?? []), frame.bounds]);
|
||||
}
|
||||
return Array.from(rows.entries())
|
||||
.sort(([left], [right]) => left - right)
|
||||
.map(([, rects]) => {
|
||||
const left = Math.min(...rects.map((rect) => rect.left));
|
||||
const right = Math.max(...rects.map((rect) => rect.right));
|
||||
return right - left;
|
||||
});
|
||||
}
|
||||
|
||||
describe('stable slot layout', () => {
|
||||
it('packs six legacy radial owners into two row-orbit rows', () => {
|
||||
const { nodes, layout } = buildSixOwnerGraph();
|
||||
const snapshot = getSnapshot(nodes, layout);
|
||||
|
||||
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
|
||||
expect(getRowCounts(snapshot)).toEqual([3, 3]);
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 2, 2, 2]);
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]);
|
||||
});
|
||||
|
||||
it('lets six radial owners move into an empty lead-level side slot', () => {
|
||||
const { nodes, layout } = buildSixOwnerGraph();
|
||||
const snapshot = getSnapshot(nodes, layout);
|
||||
const currentFrame = snapshot.memberSlotFrameByOwnerId.get('member-0')!;
|
||||
const targetOwnerX =
|
||||
snapshot.runtimeCentralExclusion.left - 160 - currentFrame.bounds.width / 2;
|
||||
|
||||
const result = resolveNearestSlotAssignment({
|
||||
ownerId: 'member-0',
|
||||
ownerX: targetOwnerX,
|
||||
ownerY: 0,
|
||||
nodes,
|
||||
snapshot,
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
assignment: { ringIndex: 1, sectorIndex: 0 },
|
||||
previewOwnerX: targetOwnerX,
|
||||
previewOwnerY: 0,
|
||||
});
|
||||
expect(result?.displacedOwnerId).toBeUndefined();
|
||||
|
||||
const nextSnapshot = getSnapshot(nodes, {
|
||||
...layout,
|
||||
slotAssignments: {
|
||||
...layout.slotAssignments,
|
||||
'member-0': result!.assignment,
|
||||
},
|
||||
});
|
||||
expect(nextSnapshot.ownerSlotLayoutKind).toBe('row-orbit');
|
||||
expect(nextSnapshot.memberSlotFrameByOwnerId.get('member-0')).toMatchObject({
|
||||
ringIndex: 1,
|
||||
sectorIndex: 0,
|
||||
});
|
||||
});
|
||||
|
||||
it('uses three grid columns for six owners in rows layout', () => {
|
||||
const { nodes, layout } = buildSixOwnerGraph();
|
||||
const snapshot = getSnapshot(nodes, {
|
||||
...layout,
|
||||
mode: 'grid-under-lead',
|
||||
slotAssignments: {},
|
||||
});
|
||||
|
||||
expect(snapshot.ownerSlotLayoutKind).toBe('grid-under-lead');
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.ringIndex)).toEqual([0, 0, 0, 1, 1, 1]);
|
||||
expect(snapshot.memberSlotFrames.map((frame) => frame.sectorIndex)).toEqual([0, 1, 2, 0, 1, 2]);
|
||||
});
|
||||
|
||||
it('packs eight radial owners into row-orbit rows without crossing the lead exclusion', () => {
|
||||
const { nodes, layout } = buildRowOrbitGraph(8, [3, 2, 3]);
|
||||
const snapshot = getSnapshot(nodes, layout);
|
||||
|
||||
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
|
||||
expect(getRowCounts(snapshot)).toEqual([3, 2, 3]);
|
||||
|
||||
const leadRowFrames = snapshot.memberSlotFrames.filter((frame) => frame.ringIndex === 1);
|
||||
expect(leadRowFrames).toHaveLength(2);
|
||||
for (const frame of leadRowFrames) {
|
||||
expect(rectsOverlap(frame.bounds, snapshot.runtimeCentralExclusion)).toBe(false);
|
||||
if (frame.ownerX < 0) {
|
||||
expect(frame.bounds.right).toBeLessThanOrEqual(snapshot.runtimeCentralExclusion.left - 160);
|
||||
} else {
|
||||
expect(frame.bounds.left).toBeGreaterThanOrEqual(
|
||||
snapshot.runtimeCentralExclusion.right + 160
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('packs twelve radial owners into four safe rows with no four-column row width', () => {
|
||||
const { nodes, layout } = buildRowOrbitGraph(12, [3, 3, 3, 3]);
|
||||
const snapshot = getSnapshot(nodes, layout);
|
||||
|
||||
expect(snapshot.ownerSlotLayoutKind).toBe('row-orbit');
|
||||
expect(getRowCounts(snapshot)).toEqual([3, 3, 3, 3]);
|
||||
|
||||
const maxFrameWidth = Math.max(...snapshot.memberSlotFrames.map((frame) => frame.bounds.width));
|
||||
const maxRowWidth = Math.max(...getRowWidths(snapshot));
|
||||
expect(maxRowWidth).toBeLessThan(maxFrameWidth * 4);
|
||||
});
|
||||
|
||||
it('swaps with the nearest existing row-orbit slot while dragging', () => {
|
||||
const { nodes, layout } = buildRowOrbitGraph(8, [3, 2, 3]);
|
||||
const snapshot = getSnapshot(nodes, layout);
|
||||
const currentFrame = snapshot.memberSlotFrameByOwnerId.get('member-0')!;
|
||||
const targetFrame = snapshot.memberSlotFrameByOwnerId.get('member-4')!;
|
||||
|
||||
const result = resolveNearestSlotAssignment({
|
||||
ownerId: 'member-0',
|
||||
ownerX: targetFrame.ownerX,
|
||||
ownerY: targetFrame.ownerY,
|
||||
nodes,
|
||||
snapshot,
|
||||
layout,
|
||||
});
|
||||
|
||||
expect(result).toEqual({
|
||||
assignment: {
|
||||
ringIndex: targetFrame.ringIndex,
|
||||
sectorIndex: targetFrame.sectorIndex,
|
||||
},
|
||||
displacedOwnerId: 'member-4',
|
||||
displacedAssignment: {
|
||||
ringIndex: currentFrame.ringIndex,
|
||||
sectorIndex: currentFrame.sectorIndex,
|
||||
},
|
||||
previewOwnerX: targetFrame.ownerX,
|
||||
previewOwnerY: targetFrame.ownerY,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -108,10 +108,28 @@ describe('ProcessBootstrapTransportEvidence', () => {
|
|||
|
||||
expect(summary).not.toBeNull();
|
||||
expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe(
|
||||
'Bootstrap transport reached bootstrap prompt observed: prompt seen; waiting for bootstrap confirmation.'
|
||||
'Bootstrap prompt has not been submitted yet. Last transport stage: bootstrap prompt observed: prompt seen.'
|
||||
);
|
||||
expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe(
|
||||
'Teammate was registered but did not bootstrap-confirm before timeout. Last transport stage: bootstrap prompt observed: prompt seen'
|
||||
'Bootstrap prompt was not submitted before timeout. Last transport stage: bootstrap prompt observed: prompt seen'
|
||||
);
|
||||
});
|
||||
|
||||
it('distinguishes submitted bootstrap prompts from not-submitted transport timeouts', () => {
|
||||
const summary = summarizeProcessBootstrapTransportEvents([
|
||||
{
|
||||
type: 'bootstrap_submitted',
|
||||
timestamp: '2026-05-07T10:00:02.000Z',
|
||||
detail: 'messageId=abc',
|
||||
},
|
||||
]);
|
||||
|
||||
expect(summary).not.toBeNull();
|
||||
expect(buildProcessBootstrapPendingDiagnostic(summary!)).toBe(
|
||||
'Bootstrap prompt was submitted; waiting for bootstrap confirmation. Last transport stage: bootstrap submitted: messageId=abc.'
|
||||
);
|
||||
expect(buildProcessBootstrapTimeoutDiagnostic(summary!)).toBe(
|
||||
'Bootstrap prompt was submitted, but teammate did not bootstrap-confirm before timeout. Last transport stage: bootstrap submitted: messageId=abc'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13734,7 +13734,7 @@ describe('TeamProvisioningService', () => {
|
|||
runtimeDiagnosticSeverity: 'warning',
|
||||
});
|
||||
expect(result.statuses.jack?.runtimeDiagnostic).toContain(
|
||||
'Bootstrap transport reached bootstrap submit rejected'
|
||||
'Bootstrap prompt has not been submitted yet. Last transport stage: bootstrap submit rejected'
|
||||
);
|
||||
});
|
||||
|
||||
|
|
|
|||
250
test/main/services/team/TeamTaskActivityIntervalService.test.ts
Normal file
250
test/main/services/team/TeamTaskActivityIntervalService.test.ts
Normal file
|
|
@ -0,0 +1,250 @@
|
|||
import * as fs from 'fs/promises';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { setClaudeBasePathOverride } from '../../../../src/main/utils/pathDecoder';
|
||||
import { TeamTaskActivityIntervalService } from '../../../../src/main/services/team/TeamTaskActivityIntervalService';
|
||||
|
||||
let tempDir = '';
|
||||
|
||||
async function writeTask(teamName: string, task: Record<string, unknown>): Promise<void> {
|
||||
const taskDir = path.join(tempDir, 'tasks', teamName);
|
||||
const taskId = String(task.id);
|
||||
await fs.mkdir(taskDir, { recursive: true });
|
||||
await fs.writeFile(path.join(taskDir, `${taskId}.json`), JSON.stringify(task, null, 2), 'utf8');
|
||||
}
|
||||
|
||||
async function readTask(teamName: string, taskId: string): Promise<Record<string, unknown>> {
|
||||
return JSON.parse(
|
||||
await fs.readFile(path.join(tempDir, 'tasks', teamName, `${taskId}.json`), 'utf8')
|
||||
) as Record<string, unknown>;
|
||||
}
|
||||
|
||||
describe('TeamTaskActivityIntervalService', () => {
|
||||
beforeEach(async () => {
|
||||
tempDir = await fs.mkdtemp(path.join(os.tmpdir(), 'team-task-activity-'));
|
||||
setClaudeBasePathOverride(tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
vi.useRealTimers();
|
||||
setClaudeBasePathOverride(null);
|
||||
if (tempDir) {
|
||||
await fs.rm(tempDir, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it('pauses all active work and review intervals for a team without changing task status', async () => {
|
||||
await writeTask('alpha', {
|
||||
id: 'task-1',
|
||||
subject: 'Build',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
|
||||
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:05:00.000Z' }],
|
||||
historyEvents: [],
|
||||
});
|
||||
|
||||
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForTeam(
|
||||
'alpha',
|
||||
'2026-05-08T10:10:00.000Z'
|
||||
);
|
||||
const task = await readTask('alpha', 'task-1');
|
||||
|
||||
expect(result.changedTasks).toBe(1);
|
||||
expect(task.status).toBe('in_progress');
|
||||
expect(task.workIntervals).toEqual([
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:10:00.000Z' },
|
||||
]);
|
||||
expect(task.reviewIntervals).toEqual([
|
||||
{
|
||||
reviewer: 'alice',
|
||||
startedAt: '2026-05-08T10:05:00.000Z',
|
||||
completedAt: '2026-05-08T10:10:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('pauses only the selected member work and review intervals', async () => {
|
||||
await writeTask('alpha', {
|
||||
id: 'bob-task',
|
||||
subject: 'Bob',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
|
||||
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:01:00.000Z' }],
|
||||
historyEvents: [],
|
||||
});
|
||||
await writeTask('alpha', {
|
||||
id: 'tom-task',
|
||||
subject: 'Tom',
|
||||
owner: 'tom',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
|
||||
reviewIntervals: [{ reviewer: 'bob', startedAt: '2026-05-08T10:02:00.000Z' }],
|
||||
historyEvents: [],
|
||||
});
|
||||
|
||||
const result = new TeamTaskActivityIntervalService().pauseActiveIntervalsForMember(
|
||||
'alpha',
|
||||
'bob',
|
||||
'2026-05-08T10:05:00.000Z'
|
||||
);
|
||||
|
||||
expect(result.changedTasks).toBe(2);
|
||||
expect((await readTask('alpha', 'bob-task')).workIntervals).toEqual([
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||
]);
|
||||
expect((await readTask('alpha', 'bob-task')).reviewIntervals).toEqual([
|
||||
{ reviewer: 'alice', startedAt: '2026-05-08T10:01:00.000Z' },
|
||||
]);
|
||||
expect((await readTask('alpha', 'tom-task')).workIntervals).toEqual([
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z' },
|
||||
]);
|
||||
expect((await readTask('alpha', 'tom-task')).reviewIntervals).toEqual([
|
||||
{
|
||||
reviewer: 'bob',
|
||||
startedAt: '2026-05-08T10:02:00.000Z',
|
||||
completedAt: '2026-05-08T10:05:00.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('resumes active work and current review intervals for the selected member', async () => {
|
||||
await writeTask('alpha', {
|
||||
id: 'task-1',
|
||||
subject: 'Build',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||
],
|
||||
reviewIntervals: [
|
||||
{
|
||||
reviewer: 'bob',
|
||||
startedAt: '2026-05-08T10:06:00.000Z',
|
||||
completedAt: '2026-05-08T10:08:00.000Z',
|
||||
},
|
||||
],
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'event-review-started',
|
||||
type: 'review_started',
|
||||
timestamp: '2026-05-08T10:06:00.000Z',
|
||||
actor: 'bob',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
const result = new TeamTaskActivityIntervalService().resumeActiveIntervalsForMember(
|
||||
'alpha',
|
||||
'bob',
|
||||
'2026-05-08T10:20:00.000Z'
|
||||
);
|
||||
const task = await readTask('alpha', 'task-1');
|
||||
|
||||
expect(result.changedTasks).toBe(1);
|
||||
expect(task.workIntervals).toEqual([
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:05:00.000Z' },
|
||||
{ startedAt: '2026-05-08T10:20:00.000Z' },
|
||||
]);
|
||||
expect(task.reviewIntervals).toEqual([
|
||||
{
|
||||
reviewer: 'bob',
|
||||
startedAt: '2026-05-08T10:06:00.000Z',
|
||||
completedAt: '2026-05-08T10:08:00.000Z',
|
||||
},
|
||||
{ reviewer: 'bob', startedAt: '2026-05-08T10:20:00.000Z' },
|
||||
]);
|
||||
});
|
||||
|
||||
it('repairs stale open intervals using last runtime evidence plus a small grace window', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z'));
|
||||
await writeTask('alpha', {
|
||||
id: 'task-1',
|
||||
subject: 'Build',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
|
||||
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:10:00.000Z' }],
|
||||
historyEvents: [],
|
||||
});
|
||||
|
||||
const result = new TeamTaskActivityIntervalService().repairStaleIntervalsAfterCrash('alpha', {
|
||||
version: 2,
|
||||
teamName: 'alpha',
|
||||
updatedAt: '2026-05-08T10:31:00.000Z',
|
||||
launchPhase: 'active',
|
||||
expectedMembers: ['bob', 'alice'],
|
||||
members: {
|
||||
bob: {
|
||||
name: 'bob',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
runtimeLastSeenAt: '2026-05-08T10:30:00.000Z',
|
||||
lastEvaluatedAt: '2026-05-08T10:31:00.000Z',
|
||||
},
|
||||
alice: {
|
||||
name: 'alice',
|
||||
launchState: 'confirmed_alive',
|
||||
agentToolAccepted: true,
|
||||
runtimeAlive: true,
|
||||
bootstrapConfirmed: true,
|
||||
hardFailure: false,
|
||||
lastHeartbeatAt: '2026-05-08T10:20:00.000Z',
|
||||
lastEvaluatedAt: '2026-05-08T10:31:00.000Z',
|
||||
},
|
||||
},
|
||||
summary: { confirmedCount: 2, pendingCount: 0, failedCount: 0, runtimeAlivePendingCount: 0 },
|
||||
teamLaunchState: 'clean_success',
|
||||
});
|
||||
const task = await readTask('alpha', 'task-1');
|
||||
|
||||
expect(result.changedTasks).toBe(1);
|
||||
expect(task.workIntervals).toEqual([
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:30:05.000Z' },
|
||||
]);
|
||||
expect(task.reviewIntervals).toEqual([
|
||||
{
|
||||
reviewer: 'alice',
|
||||
startedAt: '2026-05-08T10:10:00.000Z',
|
||||
completedAt: '2026-05-08T10:20:05.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
it('repairs stale open intervals near their start time when no runtime evidence exists', async () => {
|
||||
vi.useFakeTimers();
|
||||
vi.setSystemTime(new Date('2026-05-08T12:00:00.000Z'));
|
||||
await writeTask('alpha', {
|
||||
id: 'task-1',
|
||||
subject: 'Build',
|
||||
owner: 'bob',
|
||||
status: 'in_progress',
|
||||
workIntervals: [{ startedAt: '2026-05-08T10:00:00.000Z' }],
|
||||
reviewIntervals: [{ reviewer: 'alice', startedAt: '2026-05-08T10:10:00.000Z' }],
|
||||
historyEvents: [],
|
||||
});
|
||||
|
||||
const result = new TeamTaskActivityIntervalService().repairStaleIntervalsAfterCrash('alpha');
|
||||
const task = await readTask('alpha', 'task-1');
|
||||
|
||||
expect(result.changedTasks).toBe(1);
|
||||
expect(task.workIntervals).toEqual([
|
||||
{ startedAt: '2026-05-08T10:00:00.000Z', completedAt: '2026-05-08T10:00:05.000Z' },
|
||||
]);
|
||||
expect(task.reviewIntervals).toEqual([
|
||||
{
|
||||
reviewer: 'alice',
|
||||
startedAt: '2026-05-08T10:10:00.000Z',
|
||||
completedAt: '2026-05-08T10:10:05.000Z',
|
||||
},
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
@ -21,7 +21,7 @@ import {
|
|||
} from '../../../src/main/utils/electronUserDataMigration';
|
||||
|
||||
class FakeElectronApp implements ElectronUserDataMigrationApp {
|
||||
setPathCalls: Array<{ name: string; value: string }> = [];
|
||||
setPathCalls: { name: string; value: string }[] = [];
|
||||
|
||||
constructor(private userDataPath: string) {}
|
||||
|
||||
|
|
@ -74,9 +74,9 @@ describe('electron userData migration', () => {
|
|||
const parentPath = path.dirname(currentPath);
|
||||
|
||||
expect(getLegacyElectronUserDataCandidates(currentPath)).toEqual([
|
||||
path.join(parentPath, 'agent-teams-ai'),
|
||||
path.join(parentPath, 'Claude Agent Teams UI'),
|
||||
path.join(parentPath, 'claude-agent-teams-ui'),
|
||||
path.join(parentPath, 'agent-teams-ai'),
|
||||
path.join(parentPath, 'claude-devtools'),
|
||||
path.join(parentPath, 'claude-code-context'),
|
||||
]);
|
||||
|
|
@ -106,6 +106,34 @@ describe('electron userData migration', () => {
|
|||
expect(fs.existsSync(currentPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not invoke the copy migration in the default startup strategy', () => {
|
||||
const root = createTempRoot();
|
||||
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||
const currentPath = path.join(root, 'agent-teams-ai');
|
||||
const app = new FakeElectronApp(currentPath);
|
||||
|
||||
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||
|
||||
const result = migrateElectronUserDataDirectory(app, {
|
||||
copyDirectory: () => {
|
||||
throw new Error('copy should not run during default startup');
|
||||
},
|
||||
});
|
||||
|
||||
expect(result).toMatchObject({
|
||||
currentPath,
|
||||
legacyPath,
|
||||
migrated: false,
|
||||
fallbackToLegacy: false,
|
||||
reason: 'legacy-reused',
|
||||
});
|
||||
expect(app.setPathCalls).toEqual([
|
||||
{ name: 'userData', value: legacyPath },
|
||||
{ name: 'sessionData', value: legacyPath },
|
||||
]);
|
||||
expect(fs.existsSync(currentPath)).toBe(false);
|
||||
});
|
||||
|
||||
it('does not treat a cache-only new userData directory as populated', () => {
|
||||
const root = createTempRoot();
|
||||
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||
|
|
@ -132,6 +160,137 @@ describe('electron userData migration', () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it('does not treat Electron-generated shell files as populated new userData', () => {
|
||||
const root = createTempRoot();
|
||||
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||
const currentPath = path.join(root, 'agent-teams-ai');
|
||||
const app = new FakeElectronApp(currentPath);
|
||||
|
||||
writeFile(currentPath, 'Preferences', '{}');
|
||||
writeFile(currentPath, 'Cookies', 'sqlite bytes');
|
||||
writeFile(currentPath, 'DIPS', 'tracking state');
|
||||
writeFile(currentPath, 'WebStorage/QuotaManager', 'quota');
|
||||
writeFile(currentPath, '.updaterId', 'updater');
|
||||
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||
|
||||
const result = migrateElectronUserDataDirectory(app);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
currentPath,
|
||||
legacyPath,
|
||||
migrated: false,
|
||||
fallbackToLegacy: false,
|
||||
reason: 'legacy-reused',
|
||||
});
|
||||
expect(app.setPathCalls).toEqual([
|
||||
{ name: 'userData', value: legacyPath },
|
||||
{ name: 'sessionData', value: legacyPath },
|
||||
]);
|
||||
});
|
||||
|
||||
it('does not treat regenerated runtime-only folders as completed migration evidence', () => {
|
||||
const root = createTempRoot();
|
||||
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||
const currentPath = path.join(root, 'agent-teams-ai');
|
||||
const app = new FakeElectronApp(currentPath);
|
||||
|
||||
writeFile(currentPath, 'opencode-bridge/production-e2e-evidence.json', '{}');
|
||||
writeFile(currentPath, 'mcp-server/1.3.0/index.js', 'console.log("generated")');
|
||||
writeFile(currentPath, 'mcp-configs/agent-teams-mcp-generated.json', '{}');
|
||||
writeFile(currentPath, 'Local Storage/leveldb/000003.log', 'renderer local storage');
|
||||
writeFile(currentPath, 'IndexedDB/http_localhost_5173.indexeddb.leveldb/000003.log', 'idb');
|
||||
writeFile(currentPath, 'Partitions/dev/Local Storage/leveldb/000003.log', 'partition state');
|
||||
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||
|
||||
const result = migrateElectronUserDataDirectory(app);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
currentPath,
|
||||
legacyPath,
|
||||
migrated: false,
|
||||
fallbackToLegacy: false,
|
||||
reason: 'legacy-reused',
|
||||
});
|
||||
expect(app.setPathCalls).toEqual([
|
||||
{ name: 'userData', value: legacyPath },
|
||||
{ name: 'sessionData', value: legacyPath },
|
||||
]);
|
||||
});
|
||||
|
||||
it('keeps a populated new userData directory after a completed migration', () => {
|
||||
const root = createTempRoot();
|
||||
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||
const currentPath = path.join(root, 'agent-teams-ai');
|
||||
const app = new FakeElectronApp(currentPath);
|
||||
|
||||
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||
writeFile(currentPath, 'data/attachments/team-a/current.txt', 'current');
|
||||
writeFile(currentPath, 'backups/registry.json', '{}');
|
||||
|
||||
const result = migrateElectronUserDataDirectory(app);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
currentPath,
|
||||
legacyPath: null,
|
||||
migrated: false,
|
||||
fallbackToLegacy: false,
|
||||
reason: 'current-populated',
|
||||
});
|
||||
expect(app.setPathCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('prefers an already populated agent-teams-ai directory over older legacy data', () => {
|
||||
const root = createTempRoot();
|
||||
const completedNewPath = path.join(root, 'agent-teams-ai');
|
||||
const olderLegacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||
const currentPath = path.join(root, 'Agent Teams UI');
|
||||
const app = new FakeElectronApp(currentPath);
|
||||
|
||||
writeFile(currentPath, 'opencode-bridge/production-e2e-evidence.json', '{}');
|
||||
writeFile(completedNewPath, 'data/attachments/team-a/current.txt', 'current');
|
||||
writeFile(olderLegacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||
|
||||
const result = migrateElectronUserDataDirectory(app);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
currentPath,
|
||||
legacyPath: completedNewPath,
|
||||
migrated: false,
|
||||
fallbackToLegacy: false,
|
||||
reason: 'legacy-reused',
|
||||
});
|
||||
expect(app.setPathCalls).toEqual([
|
||||
{ name: 'userData', value: completedNewPath },
|
||||
{ name: 'sessionData', value: completedNewPath },
|
||||
]);
|
||||
});
|
||||
|
||||
it('uses populated agent-teams-ai when both current product-name and new package-name paths exist', () => {
|
||||
const root = createTempRoot();
|
||||
const completedNewPath = path.join(root, 'agent-teams-ai');
|
||||
const currentProductPath = path.join(root, 'Agent Teams UI');
|
||||
const app = new FakeElectronApp(currentProductPath);
|
||||
|
||||
writeFile(currentProductPath, 'data/attachments/team-a/old.txt', 'old');
|
||||
writeFile(completedNewPath, 'data/attachments/team-a/current.txt', 'current');
|
||||
|
||||
const result = migrateElectronUserDataDirectory(app);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
currentPath: currentProductPath,
|
||||
legacyPath: completedNewPath,
|
||||
migrated: false,
|
||||
fallbackToLegacy: false,
|
||||
reason: 'legacy-reused',
|
||||
});
|
||||
expect(app.setPathCalls).toEqual([
|
||||
{ name: 'userData', value: completedNewPath },
|
||||
{ name: 'sessionData', value: completedNewPath },
|
||||
]);
|
||||
expect(readFile(completedNewPath, 'data/attachments/team-a/current.txt')).toBe('current');
|
||||
expect(readFile(currentProductPath, 'data/attachments/team-a/old.txt')).toBe('old');
|
||||
});
|
||||
|
||||
it('copies legacy app-owned state and durable renderer storage without Chromium caches', async () => {
|
||||
const root = createTempRoot();
|
||||
const legacyPath = path.join(root, 'Claude Agent Teams UI');
|
||||
|
|
@ -383,12 +542,12 @@ describe('electron userData migration', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('uses the lowercase package-name legacy directory when product-name legacy data is absent', () => {
|
||||
it('uses the lowercase package-name legacy directory when product-name durable data is absent', () => {
|
||||
const root = createTempRoot();
|
||||
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||
const currentPath = path.join(root, 'Agent Teams UI');
|
||||
|
||||
writeFile(legacyPath, 'mcp-configs/legacy.json', '{}');
|
||||
writeFile(legacyPath, 'data/attachments/team-a/legacy.txt', 'legacy');
|
||||
|
||||
const app = new FakeElectronApp(currentPath);
|
||||
const result = migrateElectronUserDataDirectory(app);
|
||||
|
|
@ -404,7 +563,31 @@ describe('electron userData migration', () => {
|
|||
{ name: 'userData', value: legacyPath },
|
||||
{ name: 'sessionData', value: legacyPath },
|
||||
]);
|
||||
expect(fs.existsSync(path.join(currentPath, 'mcp-configs/legacy.json'))).toBe(false);
|
||||
expect(fs.existsSync(path.join(currentPath, 'data/attachments/team-a/legacy.txt'))).toBe(
|
||||
false
|
||||
);
|
||||
});
|
||||
|
||||
it('does not reuse non-durable legacy directories when no durable user data exists', () => {
|
||||
const root = createTempRoot();
|
||||
const legacyPath = path.join(root, 'claude-agent-teams-ui');
|
||||
const currentPath = path.join(root, 'Agent Teams UI');
|
||||
|
||||
writeFile(legacyPath, 'mcp-configs/legacy.json', '{}');
|
||||
writeFile(legacyPath, 'opencode-bridge/command-ledger.json', '{"commands":[]}');
|
||||
writeFile(legacyPath, 'Local Storage/leveldb/000003.log', 'renderer local storage');
|
||||
|
||||
const app = new FakeElectronApp(currentPath);
|
||||
const result = migrateElectronUserDataDirectory(app);
|
||||
|
||||
expect(result).toMatchObject({
|
||||
currentPath,
|
||||
legacyPath: null,
|
||||
migrated: false,
|
||||
fallbackToLegacy: false,
|
||||
reason: 'legacy-missing',
|
||||
});
|
||||
expect(app.setPathCalls).toEqual([]);
|
||||
});
|
||||
|
||||
it('prefers populated older legacy data over an empty newer legacy directory', () => {
|
||||
|
|
|
|||
|
|
@ -128,9 +128,12 @@ describe('formatTeamModelSummary', () => {
|
|||
});
|
||||
|
||||
describe('computeEffectiveTeamModel', () => {
|
||||
it('appends [1m] for anthropic models', () => {
|
||||
it('appends [1m] for Opus but keeps Sonnet on standard context', () => {
|
||||
expect(computeEffectiveTeamModel('opus', false, 'anthropic')).toBe('opus[1m]');
|
||||
expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet[1m]');
|
||||
expect(computeEffectiveTeamModel('sonnet', false, 'anthropic')).toBe('sonnet');
|
||||
expect(computeEffectiveTeamModel('claude-sonnet-4-6', false, 'anthropic')).toBe(
|
||||
'claude-sonnet-4-6'
|
||||
);
|
||||
});
|
||||
|
||||
it('falls back to the base Anthropic launch value when runtime catalog does not confirm a 1M variant', () => {
|
||||
|
|
@ -177,7 +180,7 @@ describe('computeEffectiveTeamModel', () => {
|
|||
|
||||
it('does not double-append [1m] when input already has it', () => {
|
||||
expect(computeEffectiveTeamModel('opus[1m]', false, 'anthropic')).toBe('opus[1m]');
|
||||
expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet[1m]');
|
||||
expect(computeEffectiveTeamModel('sonnet[1m]', false, 'anthropic')).toBe('sonnet');
|
||||
expect(computeEffectiveTeamModel('opus[1m][1m]', false, 'anthropic')).toBe('opus[1m]');
|
||||
});
|
||||
|
||||
|
|
@ -185,6 +188,56 @@ describe('computeEffectiveTeamModel', () => {
|
|||
expect(computeEffectiveTeamModel('', false, 'anthropic')).toBe('opus[1m]');
|
||||
});
|
||||
|
||||
it('keeps a Sonnet runtime default on standard context', () => {
|
||||
expect(
|
||||
computeEffectiveTeamModel('', false, 'anthropic', {
|
||||
providerId: 'anthropic',
|
||||
modelCatalog: {
|
||||
schemaVersion: 1,
|
||||
providerId: 'anthropic',
|
||||
source: 'anthropic-models-api',
|
||||
status: 'ready',
|
||||
fetchedAt: '2026-04-21T00:00:00.000Z',
|
||||
staleAt: '2026-04-21T00:10:00.000Z',
|
||||
defaultModelId: 'sonnet[1m]',
|
||||
defaultLaunchModel: 'sonnet[1m]',
|
||||
models: [
|
||||
{
|
||||
id: 'sonnet',
|
||||
launchModel: 'sonnet',
|
||||
displayName: 'Sonnet 4.6',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text', 'image'],
|
||||
supportsPersonality: false,
|
||||
isDefault: true,
|
||||
upgrade: false,
|
||||
source: 'anthropic-models-api',
|
||||
},
|
||||
{
|
||||
id: 'sonnet[1m]',
|
||||
launchModel: 'sonnet[1m]',
|
||||
displayName: 'Sonnet 4.6 (1M)',
|
||||
hidden: false,
|
||||
supportedReasoningEfforts: ['low', 'medium', 'high'],
|
||||
defaultReasoningEffort: null,
|
||||
inputModalities: ['text', 'image'],
|
||||
supportsPersonality: false,
|
||||
isDefault: false,
|
||||
upgrade: false,
|
||||
source: 'anthropic-models-api',
|
||||
},
|
||||
],
|
||||
diagnostics: {
|
||||
configReadState: 'ready',
|
||||
appServerState: 'healthy',
|
||||
},
|
||||
},
|
||||
})
|
||||
).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('returns base model without [1m] when limitContext is true', () => {
|
||||
expect(computeEffectiveTeamModel('opus', true, 'anthropic')).toBe('opus');
|
||||
expect(computeEffectiveTeamModel('opus[1m]', true, 'anthropic')).toBe('opus');
|
||||
|
|
|
|||
|
|
@ -975,10 +975,10 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
onValueChange: () => undefined,
|
||||
providerDisabledReasonById: {
|
||||
opencode:
|
||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.',
|
||||
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.',
|
||||
},
|
||||
providerDisabledBadgeLabelById: {
|
||||
opencode: 'side lane',
|
||||
opencode: 'team only',
|
||||
},
|
||||
})
|
||||
);
|
||||
|
|
@ -990,9 +990,9 @@ describe('TeamModelSelector disabled Codex models', () => {
|
|||
);
|
||||
expect(openCodeButton?.hasAttribute('disabled')).toBe(true);
|
||||
expect(openCodeButton?.getAttribute('title')).toBe(
|
||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.'
|
||||
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.'
|
||||
);
|
||||
expect(openCodeButton?.textContent).toContain('side lane');
|
||||
expect(openCodeButton?.textContent).toContain('team only');
|
||||
|
||||
await act(async () => {
|
||||
openCodeButton?.dispatchEvent(new MouseEvent('click', { bubbles: true }));
|
||||
|
|
|
|||
|
|
@ -135,7 +135,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
|||
name: draft.name,
|
||||
role: draft.customRole || undefined,
|
||||
workflow: draft.workflow,
|
||||
providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | undefined,
|
||||
providerId: draft.providerId as 'anthropic' | 'codex' | 'gemini' | 'opencode' | undefined,
|
||||
providerBackendId: draft.providerBackendId as 'codex-native' | undefined,
|
||||
model: draft.model,
|
||||
effort: draft.effort as 'low' | 'medium' | 'high' | undefined,
|
||||
|
|
@ -170,8 +170,7 @@ vi.mock('@renderer/components/team/members/MembersEditorSection', () => ({
|
|||
fastMode: member.fastMode,
|
||||
})),
|
||||
filterEditableMemberInputs: (members: unknown) => members,
|
||||
normalizeLeadProviderForMode: (providerId: unknown) =>
|
||||
providerId === 'opencode' ? 'anthropic' : providerId,
|
||||
normalizeLeadProviderForMode: (providerId: unknown) => providerId,
|
||||
normalizeMemberDraftForProviderMode: (member: unknown) => member,
|
||||
normalizeProviderForMode: (providerId: unknown) => providerId,
|
||||
validateMemberNameInline: () => null,
|
||||
|
|
@ -385,9 +384,9 @@ vi.mock('@renderer/components/team/dialogs/TeamModelSelector', () => ({
|
|||
computeEffectiveTeamModel: (model: string) => model || undefined,
|
||||
formatTeamModelSummary: (providerId: string, model: string, effort?: string) =>
|
||||
[providerId, model, effort].filter(Boolean).join(' '),
|
||||
OPENCODE_TEAM_LEAD_DISABLED_BADGE_LABEL: 'side lane',
|
||||
OPENCODE_TEAM_LEAD_DISABLED_REASON:
|
||||
'OpenCode is teammate-only in this phase. Use Anthropic, Codex, or Gemini as the team lead, then add OpenCode as a teammate.',
|
||||
OPENCODE_ONE_SHOT_DISABLED_BADGE_LABEL: 'team only',
|
||||
OPENCODE_ONE_SHOT_DISABLED_REASON:
|
||||
'OpenCode team launch is available for normal teams, but scheduled one-shot prompts still run through claude -p. Choose Anthropic, Codex, or Gemini for one-shot schedules.',
|
||||
}));
|
||||
|
||||
vi.mock('@renderer/components/team/dialogs/EffortLevelSelector', () => ({
|
||||
|
|
@ -745,7 +744,7 @@ describe('LaunchTeamDialog', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('normalizes saved OpenCode lead hydration away from the unsupported lead path', async () => {
|
||||
it('launches a saved pure OpenCode team with OpenCode as the lead provider', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
vi.mocked(isTeamModelAvailableForUi).mockImplementation(
|
||||
(_providerId, model, providerStatus) => providerStatus?.models?.includes(model ?? '') ?? false
|
||||
|
|
@ -769,7 +768,7 @@ describe('LaunchTeamDialog', () => {
|
|||
},
|
||||
],
|
||||
} as any;
|
||||
vi.mocked(api.teams.getSavedRequest).mockResolvedValue({
|
||||
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||
teamName: 'team-alpha',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
|
|
@ -777,7 +776,8 @@ describe('LaunchTeamDialog', () => {
|
|||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
model: 'gemini-3-pro-preview',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
],
|
||||
} as any);
|
||||
|
|
@ -812,7 +812,7 @@ describe('LaunchTeamDialog', () => {
|
|||
const opencodePrepareCalls = vi
|
||||
.mocked(runProviderPrepareDiagnostics)
|
||||
.mock.calls.filter((call) => call[0]?.providerId === 'opencode');
|
||||
expect(opencodePrepareCalls).toHaveLength(0);
|
||||
expect(opencodePrepareCalls.length).toBeGreaterThan(0);
|
||||
|
||||
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Launch team'
|
||||
|
|
@ -831,7 +831,8 @@ describe('LaunchTeamDialog', () => {
|
|||
{
|
||||
name: 'alice',
|
||||
role: 'Reviewer',
|
||||
model: '',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
},
|
||||
],
|
||||
});
|
||||
|
|
@ -840,9 +841,217 @@ describe('LaunchTeamDialog', () => {
|
|||
onLaunch.mock.calls as Array<[{ providerId?: string; model?: string }]>
|
||||
)[0]?.[0] as { providerId?: string; model?: string } | undefined;
|
||||
expect(launchRequest).toMatchObject({
|
||||
providerId: 'anthropic',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
});
|
||||
expect(launchRequest?.model).not.toBe('opencode/minimax-m2.5-free');
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks OpenCode lead launch until a model is selected', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||
teamName: 'team-alpha',
|
||||
providerId: 'opencode',
|
||||
model: '',
|
||||
members: [{ name: 'alice', role: 'Reviewer', providerId: 'opencode' }],
|
||||
} as any);
|
||||
const onLaunch = vi.fn(async () => {});
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LaunchTeamDialog, {
|
||||
mode: 'launch',
|
||||
open: true,
|
||||
teamName: 'team-alpha',
|
||||
members: [],
|
||||
defaultProjectPath: '/tmp/project',
|
||||
provisioningError: null,
|
||||
clearProvisioningError: vi.fn(),
|
||||
activeTeams: [],
|
||||
onClose: vi.fn(),
|
||||
onLaunch,
|
||||
})
|
||||
);
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenCode lead requires a selected model.');
|
||||
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Launch team'
|
||||
);
|
||||
expect(submitButton?.hasAttribute('disabled')).toBe(true);
|
||||
expect(onLaunch).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
it('blocks OpenCode lead launch without an OpenCode teammate', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||
teamName: 'team-alpha',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
members: [],
|
||||
} as any);
|
||||
const onLaunch = vi.fn(async () => {});
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LaunchTeamDialog, {
|
||||
mode: 'launch',
|
||||
open: true,
|
||||
teamName: 'team-alpha',
|
||||
members: [],
|
||||
defaultProjectPath: '/tmp/project',
|
||||
provisioningError: null,
|
||||
clearProvisioningError: vi.fn(),
|
||||
activeTeams: [],
|
||||
onClose: vi.fn(),
|
||||
onLaunch,
|
||||
})
|
||||
);
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenCode lead requires at least one OpenCode teammate.');
|
||||
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Launch team'
|
||||
);
|
||||
expect(submitButton?.hasAttribute('disabled')).toBe(true);
|
||||
expect(onLaunch).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
await flush();
|
||||
});
|
||||
});
|
||||
|
||||
it('keeps OpenCode lead mixed-provider launches blocked', async () => {
|
||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||
storeState.cliStatus = {
|
||||
flavor: 'agent_teams_orchestrator',
|
||||
providers: [
|
||||
{
|
||||
providerId: 'opencode',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'opencode_managed',
|
||||
verificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
models: ['opencode/minimax-m2.5-free'],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
providerId: 'codex',
|
||||
supported: true,
|
||||
authenticated: true,
|
||||
authMethod: 'codex_api_key',
|
||||
verificationState: 'verified',
|
||||
statusMessage: null,
|
||||
detailMessage: null,
|
||||
selectedBackendId: 'codex-native',
|
||||
resolvedBackendId: 'codex-native',
|
||||
models: ['gpt-5.4'],
|
||||
capabilities: {
|
||||
teamLaunch: true,
|
||||
oneShot: false,
|
||||
},
|
||||
},
|
||||
],
|
||||
} as any;
|
||||
vi.mocked(api.teams.getSavedRequest).mockResolvedValueOnce({
|
||||
teamName: 'team-alpha',
|
||||
providerId: 'opencode',
|
||||
model: 'opencode/minimax-m2.5-free',
|
||||
members: [{ name: 'alice', role: 'Reviewer', providerId: 'codex', model: 'gpt-5.4' }],
|
||||
} as any);
|
||||
const onLaunch = vi.fn(async () => {});
|
||||
const host = document.createElement('div');
|
||||
document.body.appendChild(host);
|
||||
const root = createRoot(host);
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(LaunchTeamDialog, {
|
||||
mode: 'launch',
|
||||
open: true,
|
||||
teamName: 'team-alpha',
|
||||
members: [],
|
||||
defaultProjectPath: '/tmp/project',
|
||||
provisioningError: null,
|
||||
clearProvisioningError: vi.fn(),
|
||||
activeTeams: [],
|
||||
onClose: vi.fn(),
|
||||
onLaunch,
|
||||
})
|
||||
);
|
||||
await flush();
|
||||
await flush();
|
||||
});
|
||||
|
||||
expect(host.textContent).toContain('OpenCode cannot lead mixed-provider teams');
|
||||
const submitButton = Array.from(host.querySelectorAll('button')).find(
|
||||
(button) => button.textContent === 'Launch team'
|
||||
);
|
||||
expect(submitButton?.hasAttribute('disabled')).toBe(true);
|
||||
expect(onLaunch).not.toHaveBeenCalled();
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
|
|
|
|||
|
|
@ -94,7 +94,7 @@ describe('analyzeTeammateRuntimeCompatibility', () => {
|
|||
|
||||
expect(result.blocksSubmission).toBe(true);
|
||||
expect(result.title).toBe('OpenCode cannot lead mixed-provider teams');
|
||||
expect(result.message).toContain('OpenCode-led mixed teams are not supported');
|
||||
expect(result.message).toContain('mixed teams cannot use OpenCode as the lead');
|
||||
expect(result.memberWarningById.bob).toContain('OpenCode cannot be the team lead');
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ import { getMemberColorByName } from '@shared/constants/memberColors';
|
|||
import type { ResolvedTeamMember } from '@shared/types';
|
||||
|
||||
describe('members editor editable input filtering', () => {
|
||||
it('normalizes OpenCode away from the team lead while keeping other multimodel providers', () => {
|
||||
expect(normalizeLeadProviderForMode('opencode', true)).toBe('anthropic');
|
||||
it('keeps OpenCode available for the team lead only when multimodel is enabled', () => {
|
||||
expect(normalizeLeadProviderForMode('opencode', true)).toBe('opencode');
|
||||
expect(normalizeLeadProviderForMode('codex', true)).toBe('codex');
|
||||
expect(normalizeLeadProviderForMode('anthropic', true)).toBe('anthropic');
|
||||
expect(normalizeLeadProviderForMode('opencode', false)).toBe('anthropic');
|
||||
|
|
|
|||
|
|
@ -228,7 +228,9 @@ function restoreWindowAnimationFrame(): void {
|
|||
originalWindowAnimationFrame.hasRequest
|
||||
? originalWindowAnimationFrame.requestAnimationFrame
|
||||
: undefined,
|
||||
originalWindowAnimationFrame.hasCancel ? originalWindowAnimationFrame.cancelAnimationFrame : undefined
|
||||
originalWindowAnimationFrame.hasCancel
|
||||
? originalWindowAnimationFrame.cancelAnimationFrame
|
||||
: undefined
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -524,9 +526,7 @@ describe('teamSlice actions', () => {
|
|||
member: 'bob',
|
||||
text: 'hello',
|
||||
});
|
||||
await store
|
||||
.getState()
|
||||
.refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending');
|
||||
await store.getState().refreshSendMessageRuntimeDeliveryStatus('my-team', 'm-opencode-pending');
|
||||
|
||||
expect(store.getState().sendMessageWarning).toBe(
|
||||
'OpenCode runtime delivery failed. Message was saved to inbox, but live delivery did not complete. Reason: OpenCode returned an empty assistant turn.'
|
||||
|
|
@ -975,6 +975,51 @@ describe('teamSlice actions', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('normalizes legacy six-owner row-orbit slots before preserving manual layout', () => {
|
||||
const store = createSliceStore();
|
||||
const members = [
|
||||
{ name: 'alice', agentId: 'agent-alice' },
|
||||
{ name: 'bob', agentId: 'agent-bob' },
|
||||
{ name: 'tom', agentId: 'agent-tom' },
|
||||
{ name: 'jack', agentId: 'agent-jack' },
|
||||
{ name: 'nova', agentId: 'agent-nova' },
|
||||
{ name: 'atlas', agentId: 'agent-atlas' },
|
||||
];
|
||||
store.setState({
|
||||
slotAssignmentsByTeam: {
|
||||
'my-team': {
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-atlas': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-jack': { ringIndex: 1, sectorIndex: 0 },
|
||||
'agent-nova': { ringIndex: 1, sectorIndex: 1 },
|
||||
'agent-tom': { ringIndex: 1, sectorIndex: 2 },
|
||||
},
|
||||
},
|
||||
graphLayoutSessionByTeam: {
|
||||
'my-team': {
|
||||
mode: 'manual',
|
||||
signature: null,
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
store.getState().ensureTeamGraphSlotAssignments('my-team', members);
|
||||
|
||||
expect(store.getState().slotAssignmentsByTeam['my-team']).toEqual({
|
||||
'agent-alice': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-atlas': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-bob': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-jack': { ringIndex: 2, sectorIndex: 0 },
|
||||
'agent-nova': { ringIndex: 2, sectorIndex: 1 },
|
||||
'agent-tom': { ringIndex: 2, sectorIndex: 2 },
|
||||
});
|
||||
expect(store.getState().graphLayoutSessionByTeam['my-team']).toEqual({
|
||||
mode: 'manual',
|
||||
signature: null,
|
||||
});
|
||||
});
|
||||
|
||||
it('resets graph slot assignments back to defaults when reopening the graph surface', () => {
|
||||
const store = createSliceStore();
|
||||
store.setState({
|
||||
|
|
@ -1352,10 +1397,7 @@ describe('teamSlice actions', () => {
|
|||
const fullPromise = store.getState().refreshTeamData('my-team', { withDedup: false });
|
||||
|
||||
expect(hoisted.getData).toHaveBeenCalledTimes(2);
|
||||
expect(hoisted.getData.mock.calls[0]).toEqual([
|
||||
'my-team',
|
||||
{ includeMemberBranches: false },
|
||||
]);
|
||||
expect(hoisted.getData.mock.calls[0]).toEqual(['my-team', { includeMemberBranches: false }]);
|
||||
expect(hoisted.getData.mock.calls[1]).toEqual(['my-team']);
|
||||
|
||||
thinRequest.resolve(thinSnapshot);
|
||||
|
|
@ -1414,7 +1456,9 @@ describe('teamSlice actions', () => {
|
|||
|
||||
hoisted.getData
|
||||
.mockImplementationOnce(() => alphaThin.promise)
|
||||
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } }))
|
||||
.mockResolvedValueOnce(
|
||||
createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })
|
||||
)
|
||||
.mockResolvedValueOnce(alphaFull);
|
||||
|
||||
const alphaSelect = store.getState().selectTeam('alpha-team');
|
||||
|
|
@ -1427,7 +1471,9 @@ describe('teamSlice actions', () => {
|
|||
|
||||
await store.getState().selectTeam('beta-team');
|
||||
|
||||
alphaThin.resolve(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha Thin' } }));
|
||||
alphaThin.resolve(
|
||||
createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha Thin' } })
|
||||
);
|
||||
await alphaSelect;
|
||||
await flushAsyncWork();
|
||||
|
||||
|
|
@ -1509,8 +1555,12 @@ describe('teamSlice actions', () => {
|
|||
const store = createSliceStore();
|
||||
|
||||
hoisted.getData
|
||||
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } }))
|
||||
.mockResolvedValueOnce(createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } }));
|
||||
.mockResolvedValueOnce(
|
||||
createTeamSnapshot({ teamName: 'alpha-team', config: { name: 'Alpha' } })
|
||||
)
|
||||
.mockResolvedValueOnce(
|
||||
createTeamSnapshot({ teamName: 'beta-team', config: { name: 'Beta' } })
|
||||
);
|
||||
|
||||
await store.getState().selectTeam('alpha-team');
|
||||
expect(__getTeamScopedTransientStateForTests('alpha-team')).toMatchObject({
|
||||
|
|
@ -3480,9 +3530,7 @@ describe('teamSlice actions', () => {
|
|||
|
||||
const result = await store.getState().retryFailedOpenCodeSecondaryLanes('my-team');
|
||||
|
||||
expect(result.failed).toEqual([
|
||||
{ memberName: 'alice', error: 'OpenRouter credits exhausted' },
|
||||
]);
|
||||
expect(result.failed).toEqual([{ memberName: 'alice', error: 'OpenRouter credits exhausted' }]);
|
||||
expect(hoisted.retryFailedOpenCodeSecondaryLanes).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshSpawnStatuses).toHaveBeenCalledWith('my-team');
|
||||
expect(refreshRuntimeSnapshot).toHaveBeenCalledWith('my-team');
|
||||
|
|
|
|||
|
|
@ -165,6 +165,42 @@ describe('memberActivityTimer', () => {
|
|||
).toBe('2026-05-07T09:35:00.000Z');
|
||||
});
|
||||
|
||||
it('anchors review timers to persisted review intervals and adds paused review time', () => {
|
||||
const task: TeamTaskWithKanban = {
|
||||
...baseTask,
|
||||
status: 'completed',
|
||||
reviewState: 'review',
|
||||
kanbanColumn: 'review',
|
||||
reviewer: 'alice',
|
||||
historyEvents: [
|
||||
{
|
||||
id: 'evt-1',
|
||||
type: 'review_started',
|
||||
from: 'review',
|
||||
to: 'review',
|
||||
actor: 'alice',
|
||||
timestamp: '2026-05-07T09:30:00.000Z',
|
||||
},
|
||||
],
|
||||
reviewIntervals: [
|
||||
{
|
||||
reviewer: 'alice',
|
||||
startedAt: '2026-05-07T09:30:00.000Z',
|
||||
completedAt: '2026-05-07T09:35:00.000Z',
|
||||
},
|
||||
{ reviewer: 'alice', startedAt: '2026-05-07T09:40:00.000Z' },
|
||||
],
|
||||
};
|
||||
|
||||
const anchor = deriveReviewActivityTimerAnchor(task, {
|
||||
teamName: 'alpha',
|
||||
memberName: 'alice',
|
||||
});
|
||||
|
||||
expect(anchor?.startedAt).toBe('2026-05-07T09:40:00.000Z');
|
||||
expect(anchor?.baseElapsedMs).toBe(300_000);
|
||||
});
|
||||
|
||||
it('pauses elapsed time while the activity is not running and resumes from the frozen value', () => {
|
||||
const timerId = createMemberActivityTimerId({
|
||||
teamName: 'alpha',
|
||||
|
|
|
|||
|
|
@ -195,6 +195,30 @@ describe('memberHelpers spawn-aware presence', () => {
|
|||
});
|
||||
});
|
||||
|
||||
it('marks long-running starting states as stale without making them failed', () => {
|
||||
const presentation = buildMemberLaunchPresentation({
|
||||
member,
|
||||
spawnStatus: 'waiting',
|
||||
spawnLaunchState: 'starting',
|
||||
spawnLivenessSource: undefined,
|
||||
spawnRuntimeAlive: false,
|
||||
spawnUpdatedAt: '2026-05-08T12:00:00.000Z',
|
||||
runtimeAdvisory: undefined,
|
||||
isLaunchSettling: true,
|
||||
isTeamAlive: true,
|
||||
isTeamProvisioning: false,
|
||||
nowMs: Date.parse('2026-05-08T12:03:00.000Z'),
|
||||
});
|
||||
|
||||
expect(presentation.presenceLabel).toBe('starting stale');
|
||||
expect(presentation.launchVisualState).toBe('starting_stale');
|
||||
expect(presentation.launchStatusLabel).toBe('starting stale');
|
||||
expect(presentation.dotClass).toContain('bg-amber-400');
|
||||
expect(presentation.dotClass).not.toContain('animate-pulse');
|
||||
expect(presentation.cardClass).not.toContain('member-waiting-shimmer');
|
||||
expect(presentation.spawnBadgeLabel).toBe('starting stale');
|
||||
});
|
||||
|
||||
it('keeps OpenCode runtime evidence states more specific than queued', () => {
|
||||
const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' };
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import {
|
|||
buildMemberLaunchDiagnosticsPayload,
|
||||
formatMemberLaunchDiagnosticsPayload,
|
||||
hasMemberLaunchDiagnosticsDetails,
|
||||
getMemberLaunchDiagnosticsErrorMessage,
|
||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||
|
||||
describe('member launch diagnostics', () => {
|
||||
|
|
@ -62,4 +63,31 @@ describe('member launch diagnostics', () => {
|
|||
expect(hasMemberLaunchDiagnosticsDetails(payload)).toBe(true);
|
||||
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"livenessKind": "shell_only"');
|
||||
});
|
||||
|
||||
it('includes the exact normalized member card error in copy diagnostics', () => {
|
||||
const payload = buildMemberLaunchDiagnosticsPayload({
|
||||
memberName: 'jack',
|
||||
spawnEntry: {
|
||||
status: 'error',
|
||||
launchState: 'failed_to_start',
|
||||
hardFailure: true,
|
||||
hardFailureReason:
|
||||
'Latest assistant message msg_123 failed with APIError - OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys',
|
||||
runtimeDiagnostic: 'persisted runtime pid is not alive',
|
||||
runtimeDiagnosticSeverity: 'error',
|
||||
updatedAt: '2026-05-08T12:00:00.000Z',
|
||||
},
|
||||
});
|
||||
|
||||
expect(payload.memberCardError).toBe(
|
||||
'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys'
|
||||
);
|
||||
expect(payload.diagnostics?.[0]).toBe(
|
||||
'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys'
|
||||
);
|
||||
expect(getMemberLaunchDiagnosticsErrorMessage(payload)).toBe(
|
||||
'OpenCode quota exhausted. Visit https://openrouter.ai/settings/keys'
|
||||
);
|
||||
expect(formatMemberLaunchDiagnosticsPayload(payload)).toContain('"memberCardError"');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -47,16 +47,31 @@ describe('resolveAnthropicLaunchModel', () => {
|
|||
availableLaunchModels: ['opus', 'opus[1m]'],
|
||||
})
|
||||
).toBe('opus');
|
||||
});
|
||||
|
||||
it('preserves limitContext requests and never manufactures 1M Haiku variants', () => {
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: 'sonnet',
|
||||
limitContext: true,
|
||||
selectedModel: DEFAULT_PROVIDER_MODEL_SELECTION,
|
||||
limitContext: false,
|
||||
defaultLaunchModel: 'sonnet[1m]',
|
||||
availableLaunchModels: ['sonnet', 'sonnet[1m]'],
|
||||
})
|
||||
).toBe('sonnet');
|
||||
});
|
||||
|
||||
it('preserves limitContext requests and never manufactures 1M Sonnet or Haiku variants', () => {
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: 'sonnet',
|
||||
limitContext: false,
|
||||
availableLaunchModels: ['sonnet', 'sonnet[1m]'],
|
||||
})
|
||||
).toBe('sonnet');
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: 'claude-sonnet-4-6',
|
||||
limitContext: false,
|
||||
availableLaunchModels: ['claude-sonnet-4-6', 'claude-sonnet-4-6[1m]'],
|
||||
})
|
||||
).toBe('claude-sonnet-4-6');
|
||||
expect(
|
||||
resolveAnthropicLaunchModel({
|
||||
selectedModel: 'haiku',
|
||||
|
|
|
|||
59
test/shared/utils/teamGraphDefaultLayout.test.ts
Normal file
59
test/shared/utils/teamGraphDefaultLayout.test.ts
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
import { describe, expect, it } from 'vitest';
|
||||
|
||||
import { buildTeamGraphDefaultLayoutSeed } from '@shared/utils/teamGraphDefaultLayout';
|
||||
|
||||
describe('team graph default layout', () => {
|
||||
function members(count: number): Array<{ name: string; agentId: string }> {
|
||||
return Array.from({ length: count }, (_, index) => ({
|
||||
name: `member-${index}`,
|
||||
agentId: `agent-${index}`,
|
||||
}));
|
||||
}
|
||||
|
||||
it('seeds six visible owners into two row-orbit rows', () => {
|
||||
const teamMembers = members(6);
|
||||
|
||||
expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({
|
||||
'agent-0': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-1': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-2': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-3': { ringIndex: 2, sectorIndex: 0 },
|
||||
'agent-4': { ringIndex: 2, sectorIndex: 1 },
|
||||
'agent-5': { ringIndex: 2, sectorIndex: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds eight visible owners into row-orbit defaults', () => {
|
||||
const teamMembers = members(8);
|
||||
|
||||
expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({
|
||||
'agent-0': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-1': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-2': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-3': { ringIndex: 1, sectorIndex: 0 },
|
||||
'agent-4': { ringIndex: 1, sectorIndex: 1 },
|
||||
'agent-5': { ringIndex: 2, sectorIndex: 0 },
|
||||
'agent-6': { ringIndex: 2, sectorIndex: 1 },
|
||||
'agent-7': { ringIndex: 2, sectorIndex: 2 },
|
||||
});
|
||||
});
|
||||
|
||||
it('seeds twelve visible owners into four row-orbit rows', () => {
|
||||
const teamMembers = members(12);
|
||||
|
||||
expect(buildTeamGraphDefaultLayoutSeed(teamMembers, teamMembers).assignments).toEqual({
|
||||
'agent-0': { ringIndex: 0, sectorIndex: 0 },
|
||||
'agent-1': { ringIndex: 0, sectorIndex: 1 },
|
||||
'agent-2': { ringIndex: 0, sectorIndex: 2 },
|
||||
'agent-3': { ringIndex: 1, sectorIndex: 0 },
|
||||
'agent-4': { ringIndex: 1, sectorIndex: 1 },
|
||||
'agent-5': { ringIndex: 1, sectorIndex: 2 },
|
||||
'agent-6': { ringIndex: 2, sectorIndex: 0 },
|
||||
'agent-7': { ringIndex: 2, sectorIndex: 1 },
|
||||
'agent-8': { ringIndex: 2, sectorIndex: 2 },
|
||||
'agent-9': { ringIndex: 3, sectorIndex: 0 },
|
||||
'agent-10': { ringIndex: 3, sectorIndex: 1 },
|
||||
'agent-11': { ringIndex: 3, sectorIndex: 2 },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -11,6 +11,7 @@ const pkg = JSON.parse(readFileSync(resolve(ROOT, 'package.json'), 'utf-8')) as
|
|||
|
||||
export default defineConfig({
|
||||
root: resolve(ROOT, 'src/renderer'),
|
||||
cacheDir: resolve(ROOT, 'node_modules/.vite/web-renderer'),
|
||||
plugins: [react()],
|
||||
server: {
|
||||
host: '127.0.0.1',
|
||||
|
|
|
|||
Loading…
Reference in a new issue