fix: harden teammate runtime lifecycle handling
This commit is contained in:
parent
0ace2a6255
commit
3240ea6406
28 changed files with 1116 additions and 111 deletions
|
|
@ -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,
|
||||
}
|
||||
: {}),
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ import {
|
|||
buildRuntimeTurnSettledSourceId,
|
||||
type RuntimeTurnSettledProvider,
|
||||
} from '../../core/domain';
|
||||
|
||||
import type {
|
||||
MemberWorkSyncHashPort,
|
||||
RuntimeTurnSettledPayloadNormalization,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
),
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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', () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue