fix: harden teammate runtime lifecycle handling

This commit is contained in:
777genius 2026-05-01 12:23:10 +03:00
parent 0ace2a6255
commit 3240ea6406
28 changed files with 1116 additions and 111 deletions

View file

@ -188,6 +188,63 @@ function appendRow(filePath, row) {
return row;
}
const RUNTIME_DELIVERY_DUPLICATE_NOTICE =
'Duplicate runtime_delivery ignored. The visible reply is already recorded for this relayOfMessageId; do not call agent-teams_message_send again with the same text unless you have new information.';
function normalizeComparableText(value) {
return String(value || '')
.trim()
.replace(/\r\n/g, '\n')
.replace(/[ \t]+/g, ' ');
}
function normalizeComparableParticipant(value) {
return String(value || '').trim().toLowerCase();
}
function getRuntimeDeliveryDuplicate(list, row) {
if (
row.source !== 'runtime_delivery' ||
typeof row.relayOfMessageId !== 'string' ||
row.relayOfMessageId.trim().length === 0
) {
return null;
}
const relayOfMessageId = row.relayOfMessageId.trim();
const from = normalizeComparableParticipant(row.from);
const to = normalizeComparableParticipant(row.to);
const text = normalizeComparableText(row.text);
if (!from || !to || !text) {
return null;
}
return (
list.find(
(candidate) =>
candidate &&
candidate.source === 'runtime_delivery' &&
String(candidate.relayOfMessageId || '').trim() === relayOfMessageId &&
normalizeComparableParticipant(candidate.from) === from &&
normalizeComparableParticipant(candidate.to) === to &&
normalizeComparableText(candidate.text) === text
) || null
);
}
function appendInboxRow(filePath, row) {
const current = readJson(filePath, []);
const list = Array.isArray(current) ? current : [];
const duplicate = getRuntimeDeliveryDuplicate(list, row);
if (duplicate) {
return { row: duplicate, deduplicated: true };
}
list.push(row);
writeJson(filePath, list);
return { row, deduplicated: false };
}
function sendInboxMessage(paths, flags) {
const memberName =
typeof flags.member === 'string' && flags.member.trim()
@ -204,11 +261,18 @@ function sendInboxMessage(paths, flags) {
to: memberName,
read: false,
});
appendRow(getInboxPath(paths, memberName), payload);
const appended = appendInboxRow(getInboxPath(paths, memberName), payload);
return {
deliveredToInbox: true,
messageId: payload.messageId,
message: payload,
messageId: appended.row.messageId,
message: appended.row,
...(appended.deduplicated
? {
deduplicated: true,
duplicateOfMessageId: appended.row.messageId,
deduplicationNotice: RUNTIME_DELIVERY_DUPLICATE_NOTICE,
}
: {}),
};
}

View file

@ -235,6 +235,44 @@ describe('agent-teams-controller API', () => {
expect(delivered.deliveredToInbox).toBe(true);
});
it('deduplicates repeated runtime_delivery replies to the same inbound message', () => {
const claudeDir = makeClaudeDir();
const configPath = path.join(claudeDir, 'teams', 'my-team', 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8'));
config.members = [
{ name: 'alice', role: 'team-lead' },
{ name: 'bob', role: 'developer', providerId: 'opencode', model: 'opencode/test-model' },
];
fs.writeFileSync(configPath, JSON.stringify(config, null, 2));
const controller = createController({ teamName: 'my-team', claudeDir });
const first = controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: 'Да, я здесь!',
source: 'runtime_delivery',
relayOfMessageId: 'msg-inbound-1',
});
const second = controller.messages.sendMessage({
to: 'user',
from: 'bob',
text: ' Да, я здесь! ',
source: 'runtime_delivery',
relayOfMessageId: 'msg-inbound-1',
});
const userInboxPath = path.join(claudeDir, 'teams', 'my-team', 'inboxes', 'user.json');
const rows = JSON.parse(fs.readFileSync(userInboxPath, 'utf8'));
expect(rows).toHaveLength(1);
expect(second).toMatchObject({
deliveredToInbox: true,
deduplicated: true,
messageId: first.messageId,
duplicateOfMessageId: first.messageId,
deduplicationNotice: expect.stringContaining('do not call agent-teams_message_send again'),
});
});
it('strips hallucinated zero task placeholder prefixes from visible messages', () => {
const claudeDir = makeClaudeDir();
const controller = createController({ teamName: 'my-team', claudeDir });

View file

@ -18,6 +18,7 @@
},
"anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.00000125,
"cache_creation_input_token_cost_above_1hr": 0.000002,
"cache_read_input_token_cost": 1e-7,
"input_cost_per_token": 0.000001,
"litellm_provider": "bedrock_converse",
@ -41,6 +42,7 @@
},
"anthropic.claude-haiku-4-5@20251001": {
"cache_creation_input_token_cost": 0.00000125,
"cache_creation_input_token_cost_above_1hr": 0.000002,
"cache_read_input_token_cost": 1e-7,
"input_cost_per_token": 0.000001,
"litellm_provider": "bedrock_converse",
@ -261,6 +263,7 @@
},
"anthropic.claude-opus-4-5-20251101-v1:0": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
@ -288,6 +291,7 @@
},
"anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
@ -317,6 +321,7 @@
},
"global.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
@ -346,6 +351,7 @@
},
"us.anthropic.claude-opus-4-6-v1": {
"cache_creation_input_token_cost": 0.000006875,
"cache_creation_input_token_cost_above_1hr": 0.000011,
"cache_read_input_token_cost": 5.5e-7,
"input_cost_per_token": 0.0000055,
"litellm_provider": "bedrock_converse",
@ -433,6 +439,7 @@
},
"anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
@ -477,6 +484,7 @@
},
"global.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",
@ -507,6 +515,7 @@
},
"us.anthropic.claude-opus-4-7": {
"cache_creation_input_token_cost": 0.000006875,
"cache_creation_input_token_cost_above_1hr": 0.000011,
"cache_read_input_token_cost": 5.5e-7,
"input_cost_per_token": 0.0000055,
"litellm_provider": "bedrock_converse",
@ -597,6 +606,7 @@
},
"anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.00000375,
"cache_creation_input_token_cost_above_1hr": 0.000006,
"cache_read_input_token_cost": 3e-7,
"input_cost_per_token": 0.000003,
"litellm_provider": "bedrock_converse",
@ -625,6 +635,7 @@
},
"global.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.00000375,
"cache_creation_input_token_cost_above_1hr": 0.000006,
"cache_read_input_token_cost": 3e-7,
"input_cost_per_token": 0.000003,
"litellm_provider": "bedrock_converse",
@ -653,6 +664,7 @@
},
"us.anthropic.claude-sonnet-4-6": {
"cache_creation_input_token_cost": 0.000004125,
"cache_creation_input_token_cost_above_1hr": 0.0000066,
"cache_read_input_token_cost": 3.3e-7,
"input_cost_per_token": 0.0000033,
"litellm_provider": "bedrock_converse",
@ -767,11 +779,13 @@
},
"anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.00000375,
"cache_creation_input_token_cost_above_1hr": 0.000006,
"cache_read_input_token_cost": 3e-7,
"input_cost_per_token": 0.000003,
"input_cost_per_token_above_200k_tokens": 0.000006,
"output_cost_per_token_above_200k_tokens": 0.0000225,
"cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012,
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 200000,
@ -2678,11 +2692,13 @@
},
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.00000375,
"cache_creation_input_token_cost_above_1hr": 0.000006,
"cache_read_input_token_cost": 3e-7,
"input_cost_per_token": 0.000003,
"input_cost_per_token_above_200k_tokens": 0.000006,
"output_cost_per_token_above_200k_tokens": 0.0000225,
"cache_creation_input_token_cost_above_200k_tokens": 0.0000075,
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.000012,
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 200000,
@ -2739,6 +2755,7 @@
},
"global.anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.00000125,
"cache_creation_input_token_cost_above_1hr": 0.000002,
"cache_read_input_token_cost": 1e-7,
"input_cost_per_token": 0.000001,
"litellm_provider": "bedrock_converse",
@ -3493,6 +3510,7 @@
},
"us.anthropic.claude-haiku-4-5-20251001-v1:0": {
"cache_creation_input_token_cost": 0.000001375,
"cache_creation_input_token_cost_above_1hr": 0.0000022,
"cache_read_input_token_cost": 1.1e-7,
"input_cost_per_token": 0.0000011,
"litellm_provider": "bedrock_converse",
@ -3644,11 +3662,13 @@
},
"us.anthropic.claude-sonnet-4-5-20250929-v1:0": {
"cache_creation_input_token_cost": 0.000004125,
"cache_creation_input_token_cost_above_1hr": 0.0000066,
"cache_read_input_token_cost": 3.3e-7,
"input_cost_per_token": 0.0000033,
"input_cost_per_token_above_200k_tokens": 0.0000066,
"output_cost_per_token_above_200k_tokens": 0.00002475,
"cache_creation_input_token_cost_above_200k_tokens": 0.00000825,
"cache_creation_input_token_cost_above_1hr_above_200k_tokens": 0.0000132,
"cache_read_input_token_cost_above_200k_tokens": 6.6e-7,
"litellm_provider": "bedrock_converse",
"max_input_tokens": 200000,
@ -3749,6 +3769,7 @@
},
"us.anthropic.claude-opus-4-5-20251101-v1:0": {
"cache_creation_input_token_cost": 0.000006875,
"cache_creation_input_token_cost_above_1hr": 0.000011,
"cache_read_input_token_cost": 5.5e-7,
"input_cost_per_token": 0.0000055,
"litellm_provider": "bedrock_converse",
@ -3776,6 +3797,7 @@
},
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
"cache_creation_input_token_cost": 0.00000625,
"cache_creation_input_token_cost_above_1hr": 0.00001,
"cache_read_input_token_cost": 5e-7,
"input_cost_per_token": 0.000005,
"litellm_provider": "bedrock_converse",

View file

@ -1,5 +1,6 @@
import type { MemberWorkSyncOutboxItem } from '../../contracts';
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
import type { MemberWorkSyncOutboxItem } from '../../contracts';
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;

View file

@ -1,10 +1,11 @@
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
import type { RuntimeTurnSettledEvent } from '../domain';
import type {
MemberWorkSyncAuditJournalPort,
MemberWorkSyncClockPort,
MemberWorkSyncLoggerPort,
} from './ports';
import type { RuntimeTurnSettledEvent } from '../domain';
import type {
RuntimeTurnSettledEventStorePort,
RuntimeTurnSettledPayloadNormalizerPort,

View file

@ -1,6 +1,6 @@
export * from './MemberWorkSyncAudit';
export * from './MemberWorkSyncDiagnosticsReader';
export * from './MemberWorkSyncMetricsReader';
export * from './MemberWorkSyncAudit';
export * from './MemberWorkSyncNudgeDispatcher';
export * from './MemberWorkSyncNudgeOutboxPlanner';
export * from './MemberWorkSyncPendingReportIntentReplayer';

View file

@ -142,11 +142,10 @@ export class MemberWorkSyncTeamChangeRouter {
runAfterMs?: number
): Promise<void> {
const activeMembers = await this.rosterSource.loadActiveMemberNames(teamName);
if (this.materializer) {
const materializer = this.materializer;
if (materializer) {
await Promise.allSettled(
activeMembers.map((memberName) =>
this.materializer?.materializeMember(teamName, memberName)
)
activeMembers.map((memberName) => materializer.materializeMember(teamName, memberName))
);
}
for (const memberName of activeMembers) {

View file

@ -20,8 +20,8 @@ import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTas
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer';
import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer';
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal';
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore';
import {

View file

@ -1,8 +1,7 @@
import { withFileLock } from '@main/services/team/fileLock';
import { appendFile, mkdir, rename, rm, stat } from 'fs/promises';
import { dirname } from 'path';
import { withFileLock } from '@main/services/team/fileLock';
import type {
MemberWorkSyncAuditEvent,
MemberWorkSyncAuditJournalPort,

View file

@ -2,13 +2,14 @@ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promis
import path from 'path';
import { isRuntimeTurnSettledProvider } from '../../core/domain';
import type { RuntimeTurnSettledProvider } from '../../core/domain';
import type {
RuntimeTurnSettledClaimedPayload,
RuntimeTurnSettledEventStorePort,
RuntimeTurnSettledInvalidResult,
RuntimeTurnSettledProcessedResult,
} from '../../core/application';
import type { RuntimeTurnSettledProvider } from '../../core/domain';
import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;

View file

@ -1,6 +1,5 @@
import { join } from 'path';
import { TeamMemberStoragePaths } from '@main/services/team/TeamMemberStoragePaths';
import { join } from 'path';
export class MemberWorkSyncStorePaths {
private readonly memberStorage: TeamMemberStoragePaths;

View file

@ -2,6 +2,7 @@ import {
buildRuntimeTurnSettledSourceId,
type RuntimeTurnSettledProvider,
} from '../../core/domain';
import type {
MemberWorkSyncHashPort,
RuntimeTurnSettledPayloadNormalization,

View file

@ -1,7 +1,7 @@
import { ShellRuntimeTurnSettledHookScriptInstaller } from './ShellRuntimeTurnSettledHookScriptInstaller';
import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
import { buildRuntimeTurnSettledEnvironment } from './runtimeTurnSettledEnvironment';
import { buildRuntimeTurnSettledHookSettings } from './runtimeTurnSettledHookSettings';
import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
import { ShellRuntimeTurnSettledHookScriptInstaller } from './ShellRuntimeTurnSettledHookScriptInstaller';
import type { RuntimeTurnSettledProvider } from '../../core/domain';

View file

@ -47,11 +47,19 @@ export class TeamInboxWriter {
...(request.slashCommand && { slashCommand: request.slashCommand }),
...(request.commandOutput && { commandOutput: request.commandOutput }),
};
let resultMessageId = messageId;
let resultDeduplicated = false;
await withFileLock(inboxPath, async () => {
await withInboxLock(inboxPath, async () => {
for (let attempt = 0; attempt < 3; attempt++) {
const list = await this.readInbox(inboxPath);
const duplicate = this.findRuntimeDeliveryDuplicate(list, payload);
if (duplicate) {
resultMessageId = duplicate.messageId ?? messageId;
resultDeduplicated = true;
return;
}
list.push(payload);
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
const written = await this.readInbox(inboxPath);
@ -66,10 +74,56 @@ export class TeamInboxWriter {
return {
deliveredToInbox: true,
messageId,
messageId: resultMessageId,
...(resultDeduplicated ? { deduplicated: true } : {}),
};
}
private findRuntimeDeliveryDuplicate(
messages: readonly InboxMessage[],
payload: InboxMessage
): InboxMessage | null {
if (
payload.source !== 'runtime_delivery' ||
typeof payload.relayOfMessageId !== 'string' ||
payload.relayOfMessageId.trim().length === 0
) {
return null;
}
const relayOfMessageId = payload.relayOfMessageId.trim();
const from = this.normalizeComparableParticipant(payload.from);
const to = this.normalizeComparableParticipant(payload.to);
const text = this.normalizeComparableText(payload.text);
if (!from || !to || !text) {
return null;
}
return (
messages.find(
(candidate) =>
candidate.source === 'runtime_delivery' &&
(candidate.relayOfMessageId ?? '').trim() === relayOfMessageId &&
this.normalizeComparableParticipant(candidate.from) === from &&
this.normalizeComparableParticipant(candidate.to) === to &&
this.normalizeComparableText(candidate.text) === text
) ?? null
);
}
private normalizeComparableParticipant(value: unknown): string {
return typeof value === 'string' ? value.trim().toLowerCase() : '';
}
private normalizeComparableText(value: unknown): string {
return typeof value === 'string'
? value
.trim()
.replace(/\r\n/g, '\n')
.replace(/[ \t]+/g, ' ')
: '';
}
private async readInbox(inboxPath: string): Promise<InboxMessage[]> {
let raw: string;
try {

View file

@ -122,6 +122,67 @@ function normalizeOptionalString(value: unknown): string | undefined {
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : undefined;
}
function decodeJsonStringLiteral(value: string): string {
try {
return JSON.parse(`"${value}"`) as string;
} catch {
return value.replace(/\\"/g, '"').replace(/\\n/g, '\n').replace(/\\\\/g, '\\');
}
}
function extractLooseJsonStringField(text: string, fieldName: string): string | undefined {
const strictMatch = new RegExp(`"${fieldName}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)"`).exec(text);
if (strictMatch?.[1]) {
return decodeJsonStringLiteral(strictMatch[1]).trim() || undefined;
}
const looseMatch = new RegExp(`"${fieldName}"\\s*:\\s*"((?:\\\\.|[^"\\\\])*)$`).exec(text);
return looseMatch?.[1] ? decodeJsonStringLiteral(looseMatch[1]).trim() || undefined : undefined;
}
function joinUniqueReasonParts(parts: readonly (string | undefined)[]): string | undefined {
const uniqueParts = Array.from(
new Set(parts.map((part) => part?.trim()).filter((part): part is string => !!part))
);
return uniqueParts.length > 0 ? uniqueParts.join(': ') : undefined;
}
function extractMessageSendRoutingReason(text: string): string | undefined {
const trimmed = text.trim();
if (!trimmed.includes('Message sent to') && !trimmed.includes('"routing"')) {
return undefined;
}
try {
const parsed = JSON.parse(trimmed) as {
success?: unknown;
message?: unknown;
routing?: { summary?: unknown; content?: unknown };
};
if (parsed.success === true && parsed.routing && typeof parsed.routing === 'object') {
return joinUniqueReasonParts([
typeof parsed.routing.summary === 'string' ? parsed.routing.summary : undefined,
typeof parsed.routing.content === 'string' ? parsed.routing.content : undefined,
]);
}
} catch {
// Fall through to loose extraction for persisted reasons truncated by older builds.
}
return joinUniqueReasonParts([
extractLooseJsonStringField(trimmed, 'summary'),
extractLooseJsonStringField(trimmed, 'content'),
]);
}
export function normalizeLaunchFailureReasonText(value: unknown): string | undefined {
const text = normalizeOptionalString(value);
if (!text) {
return undefined;
}
return extractMessageSendRoutingReason(text) ?? text;
}
function normalizeMemberName(name: string): string {
return name.trim();
}
@ -148,8 +209,8 @@ function buildDiagnostics(
} else if (member.runtimeAlive && !member.bootstrapConfirmed) {
diagnostics.push('waiting for teammate check-in');
}
if (member.hardFailureReason)
diagnostics.push(`hard failure reason: ${member.hardFailureReason}`);
const hardFailureReason = normalizeLaunchFailureReasonText(member.hardFailureReason);
if (hardFailureReason) diagnostics.push(`hard failure reason: ${hardFailureReason}`);
if (member.skippedForLaunch) {
diagnostics.push(
member.skipReason
@ -473,9 +534,7 @@ function normalizePersistedMemberState(
hardFailure,
hardFailureReason: !hardFailure
? undefined
: typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
? parsed.hardFailureReason.trim()
: undefined,
: normalizeLaunchFailureReasonText(parsed.hardFailureReason),
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
parsed.pendingPermissionRequestIds
),

View file

@ -1,8 +1,7 @@
import { atomicWriteAsync } from '@main/utils/atomicWrite';
import { mkdir, readFile } from 'fs/promises';
import { join } from 'path';
import { atomicWriteAsync } from '@main/utils/atomicWrite';
export interface TeamMemberStorageMetaFile {
schemaVersion: 1;
memberName: string;

View file

@ -168,8 +168,8 @@ import {
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
import {
createRuntimeRunTombstoneStore,
RuntimeStaleEvidenceError,
type RuntimeEvidenceKind,
RuntimeStaleEvidenceError,
} from './opencode/store/RuntimeRunTombstoneStore';
import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore';
import { buildActionModeProtocol } from './actionModeInstructions';
@ -210,6 +210,7 @@ import {
createPersistedLaunchSnapshot,
deriveTeamLaunchAggregateState,
hasMixedPersistedLaunchMetadata,
normalizeLaunchFailureReasonText,
snapshotFromRuntimeMemberStatuses,
snapshotToMemberSpawnStatuses,
} from './TeamLaunchStateEvaluator';
@ -1626,7 +1627,7 @@ interface PromptSizeSummary {
lines: number;
}
const MEMBER_LAUNCH_GRACE_MS = 150_000;
const MEMBER_LAUNCH_GRACE_MS = 120_000;
const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000;
export function shouldWarnOnUnreadableMemberAuditConfig(params: {
@ -1745,6 +1746,67 @@ function isDefinitiveOpenCodePreLaunchFailure(
);
}
const OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC =
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.';
function buildOpenCodeUncommittedBootstrapDiagnostic(storage: {
manifestEntryCount: number | null;
manifestUpdatedAt: string | null;
fileNames: string[];
}): string[] {
return [
OPENCODE_UNCOMMITTED_BOOTSTRAP_DIAGNOSTIC,
`OpenCode lane manifest entries: ${storage.manifestEntryCount ?? 0}`,
...(storage.manifestUpdatedAt
? [`OpenCode lane manifest updated at: ${storage.manifestUpdatedAt}`]
: []),
storage.fileNames.length > 0
? `OpenCode lane files: ${storage.fileNames.slice(0, 8).join(', ')}`
: 'OpenCode lane files: none',
];
}
function downgradeUncommittedOpenCodeBootstrapEvidence(
evidence: TeamRuntimeMemberLaunchEvidence,
diagnostics: readonly string[]
): TeamRuntimeMemberLaunchEvidence {
const hasRuntimeHandle = hasOpenCodeRuntimeHandle(evidence);
return {
...evidence,
launchState: hasRuntimeHandle ? 'runtime_pending_bootstrap' : 'starting',
agentToolAccepted: hasRuntimeHandle,
runtimeAlive: false,
bootstrapConfirmed: false,
hardFailure: false,
hardFailureReason: undefined,
livenessKind: hasRuntimeHandle
? evidence.livenessKind === 'confirmed_bootstrap'
? 'runtime_process_candidate'
: (evidence.livenessKind ?? 'runtime_process_candidate')
: 'registered_only',
runtimeDiagnostic: hasRuntimeHandle
? 'OpenCode runtime handle is present, but bootstrap evidence was not committed.'
: 'OpenCode bootstrap confirmation was not committed to lane runtime evidence.',
runtimeDiagnosticSeverity: 'warning',
diagnostics: Array.from(new Set([...evidence.diagnostics, ...diagnostics])),
};
}
function summarizeRuntimeLaunchResultMembers(
members: Record<string, TeamRuntimeMemberLaunchEvidence>
): TeamLaunchAggregateState {
const values = Object.values(members);
if (
values.some((member) => member.launchState === 'failed_to_start' || member.hardFailure === true)
) {
return 'partial_failure';
}
if (values.length > 0 && values.every((member) => member.launchState === 'confirmed_alive')) {
return 'clean_success';
}
return 'partial_pending';
}
function hasOpenCodeRuntimeHandle(
value:
| Pick<PersistedTeamLaunchMemberState, 'runtimePid' | 'runtimeSessionId' | 'livenessKind'>
@ -2518,7 +2580,7 @@ function extractHeartbeatTimestamp(text: string, fallback?: string): string | un
}
function extractBootstrapFailureReason(text: string): string | null {
const trimmed = text.trim();
const trimmed = normalizeLaunchFailureReasonText(text) ?? text.trim();
if (!trimmed) return null;
if (isBootstrapInstructionPrompt(trimmed)) return null;
const lower = trimmed.toLowerCase();
@ -6054,9 +6116,14 @@ export class TeamProvisioningService {
return { delivered: false, reason: 'opencode_runtime_not_active' };
}
}
const inMemorySecondaryLaneRunId =
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
? this.getCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)
: null;
let runtimeRunId =
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
? (liveSecondaryLaneRunId ??
inMemorySecondaryLaneRunId ??
(await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)))
: (trackedRunId ??
(await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)));
@ -6102,8 +6169,11 @@ export class TeamProvisioningService {
}
if (
runtimeActive &&
runtimeRunId &&
laneIdentity.laneKind === 'secondary' &&
laneIdentity.laneOwnerProviderId === 'opencode'
laneIdentity.laneOwnerProviderId === 'opencode' &&
!liveSecondaryLaneRunId &&
!inMemorySecondaryLaneRunId
) {
const laneStorage = await inspectOpenCodeRuntimeLaneStorage({
teamsBasePath: getTeamsBasePath(),
@ -17556,6 +17626,64 @@ export class TeamProvisioningService {
this.emitMemberSpawnChange(run, lane.member.name);
}
private async guardCommittedOpenCodeSecondaryLaneEvidence(params: {
teamName: string;
laneId: string;
result: TeamRuntimeLaunchResult;
memberName: string;
}): Promise<TeamRuntimeLaunchResult> {
const memberEvidence = params.result.members[params.memberName];
if (!memberEvidence) {
return params.result;
}
const claimsBootstrapConfirmed =
memberEvidence.launchState === 'confirmed_alive' ||
memberEvidence.bootstrapConfirmed === true ||
memberEvidence.livenessKind === 'confirmed_bootstrap';
if (!claimsBootstrapConfirmed) {
return params.result;
}
const storage = await inspectOpenCodeRuntimeLaneStorage({
teamsBasePath: getTeamsBasePath(),
teamName: params.teamName,
laneId: params.laneId,
});
if (storage.hasRuntimeEvidenceOnDisk) {
return params.result;
}
const diagnostics = buildOpenCodeUncommittedBootstrapDiagnostic(storage);
const members = {
...params.result.members,
[params.memberName]: downgradeUncommittedOpenCodeBootstrapEvidence(
memberEvidence,
diagnostics
),
};
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: getTeamsBasePath(),
teamName: params.teamName,
laneId: params.laneId,
state: 'active',
diagnostics,
}).catch((error: unknown) => {
logger.warn(
`[${params.teamName}] Failed to annotate OpenCode lane ${params.laneId} after uncommitted bootstrap evidence: ${getErrorMessage(error)}`
);
});
const teamLaunchState = summarizeRuntimeLaunchResultMembers(members);
return {
...params.result,
launchPhase: teamLaunchState === 'clean_success' ? params.result.launchPhase : 'active',
teamLaunchState,
members,
diagnostics: Array.from(new Set([...params.result.diagnostics, ...diagnostics])),
};
}
private buildMixedPersistedLaunchSnapshotForRun(
run: ProvisioningRun,
launchPhase: PersistedTeamLaunchPhase
@ -17899,7 +18027,7 @@ export class TeamProvisioningService {
laneId: lane.laneId,
runId: lane.runId,
});
const result = await adapter.launch({
const rawResult = await adapter.launch({
runId: lane.runId,
laneId: lane.laneId,
teamName: run.teamName,
@ -17923,6 +18051,12 @@ export class TeamProvisioningService {
],
previousLaunchState,
});
const result = await this.guardCommittedOpenCodeSecondaryLaneEvidence({
teamName: run.teamName,
laneId: lane.laneId,
memberName: lane.member.name,
result: rawResult,
});
if (run.cancelRequested || run.processKilled) {
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
return;

View file

@ -13,6 +13,7 @@ import {
buildMemberAvatarMap,
buildMemberLaunchPresentation,
displayMemberName,
isOpenCodeRelaunchActionable,
} from '@renderer/utils/memberHelpers';
import {
buildMemberLaunchDiagnosticsPayload,
@ -241,11 +242,20 @@ export const MemberCard = ({
const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch;
const hasLiveLaunchControls =
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
const canRetryLaunch =
(showFailedLaunchBadge || showSkippedLaunchBadge) &&
const hasRestartMemberControl =
!isRemoved &&
!isLeadMember(member) &&
Boolean(onRestartMember) &&
hasLiveLaunchControls;
hasLiveLaunchControls &&
runtimeEntry?.restartable !== false;
const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({
member,
spawnEntry,
runtimeEntry,
});
const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable;
const canRetryLaunch =
(showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl;
const canSkipFailedLaunch =
showFailedLaunchBadge &&
!isLeadMember(member) &&
@ -258,9 +268,14 @@ export const MemberCard = ({
!isFailedLaunch &&
!isSkippedLaunch &&
(Boolean(activityTask) || !isAwaitingReply);
const handleRetryFailedLaunch = async (
event: React.MouseEvent<HTMLButtonElement>
): Promise<void> => {
const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate';
const restartActionBusyLabel = canRelaunchOpenCode
? 'Relaunching OpenCode teammate'
: 'Retrying teammate';
const restartActionErrorFallback = canRelaunchOpenCode
? 'Failed to relaunch OpenCode teammate'
: 'Failed to retry teammate';
const handleRestartMember = async (event: React.MouseEvent<HTMLButtonElement>): Promise<void> => {
event.preventDefault();
event.stopPropagation();
if (!onRestartMember || retryingLaunch) {
@ -271,7 +286,7 @@ export const MemberCard = ({
try {
await onRestartMember(member.name);
} catch (error) {
setRetryLaunchError(error instanceof Error ? error.message : 'Failed to retry teammate');
setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback);
} finally {
setRetryingLaunch(false);
}
@ -448,6 +463,29 @@ export const MemberCard = ({
>
{launchBadgeLabel}
</Badge>
{canRelaunchOpenCode ? (
<Tooltip>
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
className="rounded p-1 text-amber-300 transition-colors hover:bg-amber-500/10 hover:text-amber-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
) : (
<RotateCcw className="size-3.5" />
)}
</button>
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
</span>
) : showFailedLaunchBadge ? (
<span className="flex shrink-0 items-center gap-1">
@ -499,10 +537,10 @@ export const MemberCard = ({
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? 'Retrying teammate' : 'Retry teammate'}
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
className="rounded p-1 text-red-300 transition-colors hover:bg-red-500/10 hover:text-red-200 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch || skippingLaunch}
onClick={handleRetryFailedLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
@ -513,7 +551,7 @@ export const MemberCard = ({
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')}
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}
@ -541,10 +579,10 @@ export const MemberCard = ({
<TooltipTrigger asChild>
<button
type="button"
aria-label={retryingLaunch ? 'Retrying teammate' : 'Retry teammate'}
aria-label={retryingLaunch ? restartActionBusyLabel : restartActionIdleLabel}
className="rounded p-1 text-zinc-300 transition-colors hover:bg-zinc-500/10 hover:text-zinc-100 disabled:cursor-not-allowed disabled:opacity-60"
disabled={retryingLaunch}
onClick={handleRetryFailedLaunch}
onClick={handleRestartMember}
>
{retryingLaunch ? (
<SyncedLoader2 className="size-3.5" />
@ -555,7 +593,7 @@ export const MemberCard = ({
</TooltipTrigger>
<TooltipContent side="bottom">
{retryLaunchError ??
(retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')}
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
</TooltipContent>
</Tooltip>
) : null}

View file

@ -13,6 +13,7 @@ import {
hasMemberLaunchDiagnosticsDetails,
hasMemberLaunchDiagnosticsError,
} from '@renderer/utils/memberLaunchDiagnostics';
import { isOpenCodeRelaunchActionable } from '@renderer/utils/memberHelpers';
import {
getRuntimeMemorySourceLabel,
resolveMemberRuntimeSummary,
@ -173,10 +174,14 @@ export const MemberDetailDialog = ({
[launchParams, member, runtimeEntry, spawnEntry]
);
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
const openCodeRelaunchActionable = member
? isOpenCodeRelaunchActionable({ member, spawnEntry, runtimeEntry })
: false;
const restartInFlight =
spawnEntry?.launchState === 'starting' ||
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
spawnEntry?.launchState === 'runtime_pending_permission';
!openCodeRelaunchActionable &&
(spawnEntry?.launchState === 'starting' ||
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
spawnEntry?.launchState === 'runtime_pending_permission');
const launchDiagnosticsPayload = useMemo(
() =>
member
@ -203,7 +208,8 @@ export const MemberDetailDialog = ({
const effectiveLaunchErrorMessage = openCodeNoRuntimeEvidence
? OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE
: launchErrorMessage;
const restartButtonLabel = openCodeNoRuntimeEvidence ? 'Relaunch OpenCode' : 'Restart';
const restartButtonLabel =
openCodeNoRuntimeEvidence || openCodeRelaunchActionable ? 'Relaunch OpenCode' : 'Restart';
useEffect(() => {
if (!open || !member) {

View file

@ -229,6 +229,15 @@ export function getLaunchJoinMilestonesFromMembers({
liveSummary.failedSpawnCount +
liveSummary.skippedSpawnCount;
const liveSummaryContradictsCleanSnapshot =
snapshotMilestones.pendingSpawnCount === 0 &&
snapshotMilestones.failedSpawnCount === 0 &&
snapshotMilestones.skippedSpawnCount === 0 &&
liveSummary.observedTeammateCount > 0 &&
(liveSummary.pendingSpawnCount > 0 ||
liveSummary.failedSpawnCount > 0 ||
liveSummary.skippedSpawnCount > 0);
const liveSummaryIsMoreAdvanced =
liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount ||
liveSummary.skippedSpawnCount > snapshotMilestones.skippedSpawnCount ||
@ -239,7 +248,7 @@ export function getLaunchJoinMilestonesFromMembers({
liveSummary.pendingSpawnCount > snapshotMilestones.pendingSpawnCount) ||
liveAccountedFor > snapshotAccountedFor;
return liveSummaryIsMoreAdvanced
return liveSummaryIsMoreAdvanced || liveSummaryContradictsCleanSnapshot
? {
expectedTeammateCount,
...liveSummary,

View file

@ -13,6 +13,7 @@ import type {
MemberRuntimeAdvisory,
MemberSpawnLivenessSource,
MemberSpawnStatus,
MemberSpawnStatusEntry,
MemberStatus,
ResolvedTeamMember,
TeamAgentRuntimeEntry,
@ -131,6 +132,8 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
skipped: 'skipped',
};
const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 60 * 1000;
function isLaunchStillStarting(
spawnStatus: MemberSpawnStatus | undefined,
spawnLaunchState: MemberLaunchState | undefined,
@ -597,6 +600,110 @@ export function getMemberLaunchStatusLabel(visualState: MemberLaunchVisualState)
}
}
function getLaunchVisualStateDotClass(visualState: MemberLaunchVisualState): string | null {
switch (visualState) {
case 'permission_pending':
case 'runtime_pending':
case 'runtime_candidate':
return 'bg-amber-400 animate-pulse';
case 'registered_only':
return SPAWN_DOT_COLORS.waiting;
case 'shell_only':
return 'bg-amber-400';
case 'stale_runtime':
return STATUS_DOT_COLORS.terminated;
default:
return null;
}
}
function hasElapsedSinceIso(
value: string | undefined,
thresholdMs: number,
nowMs: number
): boolean {
if (!value) {
return false;
}
const parsed = Date.parse(value);
return Number.isFinite(parsed) && nowMs - parsed >= thresholdMs;
}
function hasBootstrapStallDiagnostic(value: string | undefined): boolean {
const normalized = value?.trim().toLowerCase() ?? '';
return (
normalized.includes('no bootstrap check-in') ||
normalized.includes('bootstrap is unconfirmed') ||
normalized.includes('bootstrap unconfirmed')
);
}
export function isOpenCodeRelaunchActionable({
member,
spawnEntry,
runtimeEntry,
nowMs = Date.now(),
}: {
member: ResolvedTeamMember;
spawnEntry?: MemberSpawnStatusEntry;
runtimeEntry?: TeamAgentRuntimeEntry;
nowMs?: number;
}): boolean {
if (member.providerId !== 'opencode' || isLeadMember(member) || member.removedAt) {
return false;
}
if (spawnEntry?.launchState === 'starting' || spawnEntry?.status === 'spawning') {
return false;
}
if (spawnEntry?.launchState === 'runtime_pending_permission') {
return false;
}
if (!spawnEntry) {
const runtimeDiagnosticIsStuck = hasBootstrapStallDiagnostic(runtimeEntry?.runtimeDiagnostic);
return (
runtimeDiagnosticIsStuck ||
runtimeEntry?.livenessKind === 'registered_only' ||
runtimeEntry?.livenessKind === 'stale_metadata'
);
}
if (
spawnEntry?.launchState === 'failed_to_start' ||
spawnEntry?.launchState === 'skipped_for_launch' ||
spawnEntry?.status === 'error' ||
spawnEntry?.status === 'skipped'
) {
return true;
}
const livenessKind = runtimeEntry?.livenessKind ?? spawnEntry?.livenessKind;
const acceptedSpawnGraceElapsed = hasElapsedSinceIso(
spawnEntry?.firstSpawnAcceptedAt,
OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS,
nowMs
);
const hasExplicitBootstrapStall =
hasBootstrapStallDiagnostic(spawnEntry?.runtimeDiagnostic) ||
hasBootstrapStallDiagnostic(runtimeEntry?.runtimeDiagnostic);
const launchIsNoLongerFresh =
spawnEntry.launchState === 'confirmed_alive' ||
spawnEntry.status === 'online' ||
acceptedSpawnGraceElapsed ||
hasExplicitBootstrapStall;
if (
livenessKind === 'registered_only' ||
livenessKind === 'stale_metadata' ||
livenessKind === 'not_found'
) {
return launchIsNoLongerFresh;
}
if (livenessKind !== 'runtime_process_candidate') {
return false;
}
return acceptedSpawnGraceElapsed || hasExplicitBootstrapStall;
}
export function buildMemberLaunchPresentation({
member,
spawnStatus,
@ -634,7 +741,7 @@ export function buildMemberLaunchPresentation({
isTeamProvisioning,
leadActivity
);
const dotClass = getSpawnAwareDotClass(
const baseDotClass = getSpawnAwareDotClass(
member,
spawnStatus,
spawnLaunchState,
@ -701,6 +808,7 @@ export function buildMemberLaunchPresentation({
}
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState);
const shouldShowLaunchStatusAsPresence =
launchVisualState === 'permission_pending' ||
launchVisualState === 'runtime_pending' ||
@ -723,7 +831,10 @@ export function buildMemberLaunchPresentation({
return {
presenceLabel: displayPresenceLabel,
dotClass: runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : dotClass,
dotClass:
runtimeAdvisoryTone === 'error'
? STATUS_DOT_COLORS.terminated
: (launchVisualStateDotClass ?? baseDotClass),
cardClass,
runtimeAdvisoryLabel,
runtimeAdvisoryTitle,

View file

@ -190,6 +190,37 @@ describe('TeamInboxWriter', () => {
});
});
it('deduplicates repeated runtime delivery replies to the same inbound message', async () => {
const first = await writer.sendMessage('my-team', {
member: 'user',
from: 'alice',
to: 'user',
text: 'Да, я здесь!',
source: 'runtime_delivery',
relayOfMessageId: 'inbound-1',
});
const second = await writer.sendMessage('my-team', {
member: 'user',
from: 'alice',
to: 'user',
text: ' Да, я здесь! ',
source: 'runtime_delivery',
relayOfMessageId: 'inbound-1',
});
const userInboxPath = '/mock/teams/my-team/inboxes/user.json';
const persisted = JSON.parse(hoisted.files.get(userInboxPath) ?? '[]') as Record<
string,
unknown
>[];
expect(persisted).toHaveLength(1);
expect(second).toMatchObject({
deliveredToInbox: true,
deduplicated: true,
messageId: first.messageId,
});
});
it('omits source field from payload when not provided in request', async () => {
await writer.sendMessage('my-team', {
member: 'alice',

View file

@ -1,12 +1,40 @@
import { describe, expect, it } from 'vitest';
import {
normalizeLaunchFailureReasonText,
normalizePersistedLaunchSnapshot,
snapshotToMemberSpawnStatuses,
summarizePersistedLaunchMembers,
} from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
describe('TeamLaunchStateEvaluator', () => {
it('normalizes message_send tool result JSON in persisted hard failure reasons', () => {
const reason = normalizeLaunchFailureReasonText(
JSON.stringify({
success: true,
message: "Message sent to team-lead's inbox",
routing: {
sender: 'tom',
target: '@team-lead',
summary: 'Bootstrap failed - no member_briefing tool',
content: 'Не могу выполнить member_briefing: tool not found.',
},
})
);
expect(reason).toBe(
'Bootstrap failed - no member_briefing tool: Не могу выполнить member_briefing: tool not found.'
);
});
it('normalizes truncated message_send tool result JSON in persisted hard failure reasons', () => {
const reason = normalizeLaunchFailureReasonText(
`{"success":true,"message":"Message sent to team-lead's inbox","routing":{"sender":"tom","summary":"Bootstrap failed - no member_briefing tool","content":"Не могу выполнить member_briefing`
);
expect(reason).toBe('Bootstrap failed - no member_briefing tool: Не могу выполнить member_briefing');
});
it('keeps member spawn statuses for persisted members even when expectedMembers is stale', () => {
const statuses = snapshotToMemberSpawnStatuses({
version: 1,

View file

@ -3232,6 +3232,63 @@ describe('TeamProvisioningService', () => {
);
});
it('does not trust OpenCode secondary bootstrap success without committed lane evidence', async () => {
const svc = new TeamProvisioningService();
await upsertOpenCodeRuntimeLaneIndexEntry({
teamsBasePath: tempTeamsBase,
teamName: 'mixed-team-no-committed-evidence',
laneId: 'secondary:opencode:bob',
state: 'active',
});
await setOpenCodeRuntimeActiveRunManifest({
teamsBasePath: tempTeamsBase,
teamName: 'mixed-team-no-committed-evidence',
laneId: 'secondary:opencode:bob',
runId: 'lane-run-bob',
});
const result = await (svc as any).guardCommittedOpenCodeSecondaryLaneEvidence({
teamName: 'mixed-team-no-committed-evidence',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
result: {
runId: 'lane-run-bob',
teamName: 'mixed-team-no-committed-evidence',
launchPhase: 'finished',
teamLaunchState: 'clean_success',
members: {
bob: {
memberName: 'bob',
providerId: 'opencode',
launchState: 'confirmed_alive',
agentToolAccepted: true,
runtimeAlive: true,
bootstrapConfirmed: true,
hardFailure: false,
diagnostics: [],
},
},
warnings: [],
diagnostics: [],
},
});
expect(result.teamLaunchState).toBe('partial_pending');
expect(result.launchPhase).toBe('active');
expect(result.members.bob).toMatchObject({
launchState: 'starting',
agentToolAccepted: false,
runtimeAlive: false,
bootstrapConfirmed: false,
livenessKind: 'registered_only',
runtimeDiagnostic:
'OpenCode bootstrap confirmation was not committed to lane runtime evidence.',
});
expect(result.members.bob.diagnostics).toContain(
'OpenCode bridge reported bootstrap confirmation, but no lane runtime evidence was committed.'
);
});
it('delivers direct messages to OpenCode secondary lanes with the lane run id', async () => {
const svc = new TeamProvisioningService();
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
@ -3334,6 +3391,14 @@ describe('TeamProvisioningService', () => {
(svc as any).getTrackedRunId = vi.fn(() => null);
(svc as any).canDeliverToOpenCodeRuntimeForTeam = vi.fn(() => true);
(svc as any).setSecondaryRuntimeRun({
teamName: 'team-a',
runId: 'opencode-run-bob',
providerId: 'opencode',
laneId: 'secondary:opencode:bob',
memberName: 'bob',
cwd: '/repo/.agent-team-worktrees/bob',
});
(svc as any).resolveCurrentOpenCodeRuntimeRunId = vi.fn(async () => 'opencode-run-bob');
(svc as any).isOpenCodeRuntimeLaneIndexActive = vi.fn(async () => true);
(svc as any).configReader = {
@ -8426,9 +8491,31 @@ describe('TeamProvisioningService', () => {
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
const expectedMembers = input.expectedMembers as Array<{ name: string }>;
const memberName = expectedMembers[0]?.name ?? 'unknown';
const teamName = String(input.teamName);
const laneId = String(input.laneId);
const runId = String(input.runId);
const manifestPath = getOpenCodeRuntimeManifestPath(tempTeamsBase, teamName, laneId);
await fsPromises.mkdir(path.dirname(manifestPath), { recursive: true });
await fsPromises.writeFile(
manifestPath,
`${JSON.stringify(
{
...createDefaultRuntimeStoreManifest(teamName, '2026-04-22T12:00:00.000Z'),
activeRunId: runId,
},
null,
2
)}\n`,
'utf8'
);
await fsPromises.writeFile(
path.join(path.dirname(manifestPath), 'opencode-sessions.json'),
`${JSON.stringify({ sessions: [{ id: `oc-session-${memberName}` }] })}\n`,
'utf8'
);
return {
runId: String(input.runId),
teamName: String(input.teamName),
runId,
teamName,
launchPhase: 'finished',
teamLaunchState: 'clean_success',
members: {
@ -9724,6 +9811,75 @@ describe('TeamProvisioningService', () => {
expect(reason).toBeNull();
});
it('extracts a human-readable bootstrap failure from message_send tool result JSON', async () => {
allowConsoleLogs();
const teamName = 'zz-unit-bootstrap-message-send-json-failure';
const leadSessionId = 'lead-session';
const memberSessionId = 'alice-session';
const projectPath = '/Users/test/proj';
const projectId = '-Users-test-proj';
const acceptedAt = new Date(Date.now() - 5_000).toISOString();
const failureAt = new Date(Date.now() - 4_000).toISOString();
writeLaunchConfig(teamName, projectPath, leadSessionId, ['alice']);
const projectRoot = path.join(tempProjectsBase, projectId);
fs.mkdirSync(projectRoot, { recursive: true });
fs.writeFileSync(
path.join(projectRoot, `${memberSessionId}.jsonl`),
[
JSON.stringify({
timestamp: acceptedAt,
teamName,
agentName: 'alice',
type: 'user',
message: {
role: 'user',
content: `You are bootstrapping into team "${teamName}" as member "alice".`,
},
}),
JSON.stringify({
timestamp: failureAt,
teamName,
agentName: 'alice',
type: 'user',
message: {
role: 'user',
content: [
{
type: 'tool_result',
tool_use_id: 'toolu-message-send',
content: JSON.stringify({
success: true,
message: "Message sent to team-lead's inbox",
routing: {
sender: 'alice',
target: '@team-lead',
summary: 'Bootstrap failed - no member_briefing tool',
content: 'Не могу выполнить member_briefing: tool not found.',
},
}),
},
],
},
}),
].join('\n') + '\n',
'utf8'
);
const svc = new TeamProvisioningService();
const reason = await (svc as any).findBootstrapTranscriptFailureReason(
teamName,
'alice',
Date.parse(acceptedAt) - 1
);
expect(reason).toBe(
'Bootstrap failed - no member_briefing tool: Не могу выполнить member_briefing: tool not found.'
);
expect(reason).not.toContain('{"success":true');
});
it('clears a stale persisted bootstrap-prompt failure when member_briefing later succeeds', async () => {
allowConsoleLogs();
const teamName = 'zz-unit-bootstrap-stale-prompt-failure';

View file

@ -86,6 +86,7 @@ const skippedSpawnEntry: MemberSpawnStatusEntry = {
describe('MemberCard starting-state visuals', () => {
afterEach(() => {
document.body.innerHTML = '';
vi.useRealTimers();
});
it('shows runtime summary while keeping the starting treatment after provisioning stops', async () => {
@ -771,6 +772,122 @@ describe('MemberCard starting-state visuals', () => {
});
});
it('renders Relaunch OpenCode for registered-only OpenCode teammates', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
const onRestartMember = vi.fn(async () => undefined);
const onClick = vi.fn();
const openCodeMember: ResolvedTeamMember = {
...member,
providerId: 'opencode',
};
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: openCodeMember,
memberColor: 'blue',
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
spawnRuntimeAlive: true,
spawnEntry: {
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: false,
bootstrapConfirmed: false,
livenessKind: 'registered_only',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeEntry: {
memberName: 'alice',
alive: false,
restartable: true,
providerId: 'opencode',
livenessKind: 'registered_only',
runtimeDiagnostic: 'registered runtime metadata without live process',
updatedAt: '2026-04-24T12:00:00.000Z',
},
onClick,
onRestartMember,
})
);
await Promise.resolve();
});
const button = host.querySelector('[aria-label="Relaunch OpenCode"]') as HTMLButtonElement;
expect(button).not.toBeNull();
await act(async () => {
button.click();
await Promise.resolve();
});
expect(onRestartMember).toHaveBeenCalledWith('alice');
expect(onClick).not.toHaveBeenCalled();
await act(async () => {
root.unmount();
await Promise.resolve();
});
});
it('does not render Relaunch OpenCode for fresh runtime candidates', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
vi.useFakeTimers();
vi.setSystemTime(new Date('2026-04-24T12:01:00.000Z'));
const host = document.createElement('div');
document.body.appendChild(host);
const root = createRoot(host);
await act(async () => {
root.render(
React.createElement(MemberCard, {
member: {
...member,
providerId: 'opencode',
},
memberColor: 'blue',
isTeamAlive: true,
isTeamProvisioning: false,
spawnStatus: 'online',
spawnLaunchState: 'runtime_pending_bootstrap',
spawnRuntimeAlive: true,
spawnEntry: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
livenessKind: 'runtime_process_candidate',
firstSpawnAcceptedAt: '2026-04-24T12:00:00.000Z',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeEntry: {
memberName: 'alice',
alive: true,
restartable: true,
providerId: 'opencode',
livenessKind: 'runtime_process_candidate',
updatedAt: '2026-04-24T12:00:00.000Z',
},
onRestartMember: vi.fn(),
})
);
await Promise.resolve();
});
expect(host.querySelector('[aria-label="Relaunch OpenCode"]')).toBeNull();
await act(async () => {
root.unmount();
await Promise.resolve();
});
vi.useRealTimers();
});
it('renders skip for failed teammate launches', async () => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
const host = document.createElement('div');

View file

@ -4,59 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import { useStore } from '@renderer/store';
import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts';
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
const apiMocks = vi.hoisted(() => ({
getMemberWorkSyncStatus: vi.fn(),
}));
function makeMemberWorkSyncStatus(
overrides: Partial<MemberWorkSyncStatus> = {}
): MemberWorkSyncStatus {
return {
teamName: 'demo-team',
memberName: 'jack',
state: 'needs_sync',
agenda: {
teamName: 'demo-team',
memberName: 'jack',
generatedAt: '2026-04-29T00:00:00.000Z',
fingerprint: 'agenda:v1:abcdef123456',
items: [
{
taskId: 'task-1',
displayId: '11111111',
subject: 'Review patch',
kind: 'work',
assignee: 'jack',
priority: 'normal',
reason: 'owned_pending_task',
evidence: { status: 'in_progress', owner: 'jack' },
},
],
diagnostics: [],
},
shadow: {
reconciledBy: 'request',
wouldNudge: true,
fingerprintChanged: false,
},
evaluatedAt: '2026-04-29T00:00:00.000Z',
diagnostics: [],
...overrides,
};
}
vi.mock('@renderer/api', () => ({
api: {
memberWorkSync: {
getStatus: apiMocks.getMemberWorkSyncStatus,
},
},
isElectronMode: () => true,
}));
vi.mock('@renderer/hooks/useMemberStats', () => ({
useMemberStats: () => ({
stats: null,
@ -166,7 +115,6 @@ import { MemberDetailDialog } from '@renderer/components/team/members/MemberDeta
describe('MemberDetailDialog activity count', () => {
beforeEach(() => {
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
apiMocks.getMemberWorkSyncStatus.mockResolvedValue(makeMemberWorkSyncStatus());
useStore.setState({
teamMessagesByName: {
'demo-team': {
@ -255,12 +203,6 @@ describe('MemberDetailDialog activity count', () => {
expect(host.textContent).toContain('activity-count:1');
expect(host.textContent).toContain('Activity1');
expect(host.textContent).toContain('Member work sync');
expect(host.textContent).toContain('Needs sync');
expect(apiMocks.getMemberWorkSyncStatus).toHaveBeenCalledWith({
teamName: 'demo-team',
memberName: 'jack',
});
await act(async () => {
root.unmount();

View file

@ -125,4 +125,54 @@ describe('getLaunchJoinMilestonesFromMembers', () => {
expect(milestones.failedSpawnCount).toBe(0);
expect(milestones.pendingSpawnCount).toBe(3);
});
it('does not let a stale clean snapshot hide live registered-only members', () => {
const milestones = getLaunchJoinMilestonesFromMembers({
members,
memberSpawnStatuses: {
alice: {
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
updatedAt: '2026-04-24T12:00:00.000Z',
},
bob: {
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
bootstrapConfirmed: false,
livenessKind: 'registered_only',
updatedAt: '2026-04-24T12:00:01.000Z',
},
tom: {
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
updatedAt: '2026-04-24T12:00:00.000Z',
},
jane: {
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: true,
bootstrapConfirmed: true,
updatedAt: '2026-04-24T12:00:00.000Z',
},
},
memberSpawnSnapshot: {
expectedMembers: ['alice', 'bob', 'tom', 'jane'],
summary: {
confirmedCount: 4,
pendingCount: 0,
failedCount: 0,
runtimeAlivePendingCount: 0,
},
},
});
expect(milestones.heartbeatConfirmedCount).toBe(3);
expect(milestones.pendingSpawnCount).toBe(1);
expect(milestones.expectedTeammateCount).toBe(4);
});
});

View file

@ -6,6 +6,7 @@ import {
getSpawnCardClass,
getMemberRuntimeAdvisoryLabel,
getMemberRuntimeAdvisoryTitle,
isOpenCodeRelaunchActionable,
} from '@renderer/utils/memberHelpers';
import type { ResolvedTeamMember } from '@shared/types';
@ -280,7 +281,152 @@ describe('memberHelpers spawn-aware presence', () => {
presenceLabel: 'bootstrap unconfirmed',
launchVisualState: 'runtime_candidate',
launchStatusLabel: 'bootstrap unconfirmed',
dotClass: expect.stringContaining('bg-amber-400'),
});
expect(
buildMemberLaunchPresentation({
member,
spawnStatus: 'online',
spawnLaunchState: 'confirmed_alive',
spawnLivenessSource: 'process',
spawnRuntimeAlive: true,
runtimeEntry: {
memberName: 'alice',
alive: false,
restartable: true,
livenessKind: 'registered_only',
runtimeDiagnostic: 'registered runtime metadata without live process',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeAdvisory: undefined,
isLaunchSettling: false,
isTeamAlive: true,
isTeamProvisioning: false,
})
).toMatchObject({
presenceLabel: 'registered',
launchVisualState: 'registered_only',
launchStatusLabel: 'registered',
dotClass: expect.stringContaining('bg-zinc-400'),
});
});
it('marks stuck OpenCode launch states as manually relaunchable', () => {
const openCodeMember: ResolvedTeamMember = { ...member, providerId: 'opencode' };
expect(
isOpenCodeRelaunchActionable({
member: openCodeMember,
spawnEntry: {
status: 'online',
launchState: 'confirmed_alive',
runtimeAlive: false,
bootstrapConfirmed: false,
livenessKind: 'registered_only',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeEntry: {
memberName: 'alice',
alive: false,
restartable: true,
providerId: 'opencode',
livenessKind: 'registered_only',
updatedAt: '2026-04-24T12:00:00.000Z',
},
})
).toBe(true);
expect(
isOpenCodeRelaunchActionable({
member: openCodeMember,
spawnEntry: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
livenessKind: 'runtime_process_candidate',
firstSpawnAcceptedAt: '2026-04-24T12:00:00.000Z',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeEntry: {
memberName: 'alice',
alive: true,
restartable: true,
providerId: 'opencode',
livenessKind: 'runtime_process_candidate',
updatedAt: '2026-04-24T12:00:00.000Z',
},
nowMs: Date.parse('2026-04-24T12:06:00.000Z'),
})
).toBe(true);
});
it('does not mark fresh OpenCode runtime candidates as relaunchable', () => {
expect(
isOpenCodeRelaunchActionable({
member: { ...member, providerId: 'opencode' },
spawnEntry: {
status: 'online',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: true,
bootstrapConfirmed: false,
livenessKind: 'runtime_process_candidate',
firstSpawnAcceptedAt: '2026-04-24T12:00:00.000Z',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeEntry: {
memberName: 'alice',
alive: true,
restartable: true,
providerId: 'opencode',
livenessKind: 'runtime_process_candidate',
updatedAt: '2026-04-24T12:00:00.000Z',
},
nowMs: Date.parse('2026-04-24T12:01:00.000Z'),
})
).toBe(false);
});
it('does not mark fresh OpenCode not-found checks as relaunchable', () => {
expect(
isOpenCodeRelaunchActionable({
member: { ...member, providerId: 'opencode' },
spawnEntry: {
status: 'waiting',
launchState: 'runtime_pending_bootstrap',
runtimeAlive: false,
bootstrapConfirmed: false,
livenessKind: 'not_found',
firstSpawnAcceptedAt: '2026-04-24T12:00:00.000Z',
updatedAt: '2026-04-24T12:00:00.000Z',
},
runtimeEntry: {
memberName: 'alice',
alive: false,
restartable: true,
providerId: 'opencode',
livenessKind: 'not_found',
updatedAt: '2026-04-24T12:00:00.000Z',
},
nowMs: Date.parse('2026-04-24T12:01:00.000Z'),
})
).toBe(false);
expect(
isOpenCodeRelaunchActionable({
member: { ...member, providerId: 'opencode' },
runtimeEntry: {
memberName: 'alice',
alive: false,
restartable: true,
providerId: 'opencode',
livenessKind: 'not_found',
updatedAt: '2026-04-24T12:00:00.000Z',
},
nowMs: Date.parse('2026-04-24T12:01:00.000Z'),
})
).toBe(false);
});
it('returns shared launch status labels without changing generic presence labels', () => {