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;
|
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) {
|
function sendInboxMessage(paths, flags) {
|
||||||
const memberName =
|
const memberName =
|
||||||
typeof flags.member === 'string' && flags.member.trim()
|
typeof flags.member === 'string' && flags.member.trim()
|
||||||
|
|
@ -204,11 +261,18 @@ function sendInboxMessage(paths, flags) {
|
||||||
to: memberName,
|
to: memberName,
|
||||||
read: false,
|
read: false,
|
||||||
});
|
});
|
||||||
appendRow(getInboxPath(paths, memberName), payload);
|
const appended = appendInboxRow(getInboxPath(paths, memberName), payload);
|
||||||
return {
|
return {
|
||||||
deliveredToInbox: true,
|
deliveredToInbox: true,
|
||||||
messageId: payload.messageId,
|
messageId: appended.row.messageId,
|
||||||
message: payload,
|
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);
|
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', () => {
|
it('strips hallucinated zero task placeholder prefixes from visible messages', () => {
|
||||||
const claudeDir = makeClaudeDir();
|
const claudeDir = makeClaudeDir();
|
||||||
const controller = createController({ teamName: 'my-team', claudeDir });
|
const controller = createController({ teamName: 'my-team', claudeDir });
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@
|
||||||
},
|
},
|
||||||
"anthropic.claude-haiku-4-5-20251001-v1:0": {
|
"anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||||
"cache_creation_input_token_cost": 0.00000125,
|
"cache_creation_input_token_cost": 0.00000125,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.000002,
|
||||||
"cache_read_input_token_cost": 1e-7,
|
"cache_read_input_token_cost": 1e-7,
|
||||||
"input_cost_per_token": 0.000001,
|
"input_cost_per_token": 0.000001,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -41,6 +42,7 @@
|
||||||
},
|
},
|
||||||
"anthropic.claude-haiku-4-5@20251001": {
|
"anthropic.claude-haiku-4-5@20251001": {
|
||||||
"cache_creation_input_token_cost": 0.00000125,
|
"cache_creation_input_token_cost": 0.00000125,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.000002,
|
||||||
"cache_read_input_token_cost": 1e-7,
|
"cache_read_input_token_cost": 1e-7,
|
||||||
"input_cost_per_token": 0.000001,
|
"input_cost_per_token": 0.000001,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -261,6 +263,7 @@
|
||||||
},
|
},
|
||||||
"anthropic.claude-opus-4-5-20251101-v1:0": {
|
"anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||||
"cache_read_input_token_cost": 5e-7,
|
"cache_read_input_token_cost": 5e-7,
|
||||||
"input_cost_per_token": 0.000005,
|
"input_cost_per_token": 0.000005,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -288,6 +291,7 @@
|
||||||
},
|
},
|
||||||
"anthropic.claude-opus-4-6-v1": {
|
"anthropic.claude-opus-4-6-v1": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||||
"cache_read_input_token_cost": 5e-7,
|
"cache_read_input_token_cost": 5e-7,
|
||||||
"input_cost_per_token": 0.000005,
|
"input_cost_per_token": 0.000005,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -317,6 +321,7 @@
|
||||||
},
|
},
|
||||||
"global.anthropic.claude-opus-4-6-v1": {
|
"global.anthropic.claude-opus-4-6-v1": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||||
"cache_read_input_token_cost": 5e-7,
|
"cache_read_input_token_cost": 5e-7,
|
||||||
"input_cost_per_token": 0.000005,
|
"input_cost_per_token": 0.000005,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -346,6 +351,7 @@
|
||||||
},
|
},
|
||||||
"us.anthropic.claude-opus-4-6-v1": {
|
"us.anthropic.claude-opus-4-6-v1": {
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.000011,
|
||||||
"cache_read_input_token_cost": 5.5e-7,
|
"cache_read_input_token_cost": 5.5e-7,
|
||||||
"input_cost_per_token": 0.0000055,
|
"input_cost_per_token": 0.0000055,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -433,6 +439,7 @@
|
||||||
},
|
},
|
||||||
"anthropic.claude-opus-4-7": {
|
"anthropic.claude-opus-4-7": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||||
"cache_read_input_token_cost": 5e-7,
|
"cache_read_input_token_cost": 5e-7,
|
||||||
"input_cost_per_token": 0.000005,
|
"input_cost_per_token": 0.000005,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -477,6 +484,7 @@
|
||||||
},
|
},
|
||||||
"global.anthropic.claude-opus-4-7": {
|
"global.anthropic.claude-opus-4-7": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||||
"cache_read_input_token_cost": 5e-7,
|
"cache_read_input_token_cost": 5e-7,
|
||||||
"input_cost_per_token": 0.000005,
|
"input_cost_per_token": 0.000005,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -507,6 +515,7 @@
|
||||||
},
|
},
|
||||||
"us.anthropic.claude-opus-4-7": {
|
"us.anthropic.claude-opus-4-7": {
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.000011,
|
||||||
"cache_read_input_token_cost": 5.5e-7,
|
"cache_read_input_token_cost": 5.5e-7,
|
||||||
"input_cost_per_token": 0.0000055,
|
"input_cost_per_token": 0.0000055,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -597,6 +606,7 @@
|
||||||
},
|
},
|
||||||
"anthropic.claude-sonnet-4-6": {
|
"anthropic.claude-sonnet-4-6": {
|
||||||
"cache_creation_input_token_cost": 0.00000375,
|
"cache_creation_input_token_cost": 0.00000375,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.000006,
|
||||||
"cache_read_input_token_cost": 3e-7,
|
"cache_read_input_token_cost": 3e-7,
|
||||||
"input_cost_per_token": 0.000003,
|
"input_cost_per_token": 0.000003,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -625,6 +635,7 @@
|
||||||
},
|
},
|
||||||
"global.anthropic.claude-sonnet-4-6": {
|
"global.anthropic.claude-sonnet-4-6": {
|
||||||
"cache_creation_input_token_cost": 0.00000375,
|
"cache_creation_input_token_cost": 0.00000375,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.000006,
|
||||||
"cache_read_input_token_cost": 3e-7,
|
"cache_read_input_token_cost": 3e-7,
|
||||||
"input_cost_per_token": 0.000003,
|
"input_cost_per_token": 0.000003,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -653,6 +664,7 @@
|
||||||
},
|
},
|
||||||
"us.anthropic.claude-sonnet-4-6": {
|
"us.anthropic.claude-sonnet-4-6": {
|
||||||
"cache_creation_input_token_cost": 0.000004125,
|
"cache_creation_input_token_cost": 0.000004125,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.0000066,
|
||||||
"cache_read_input_token_cost": 3.3e-7,
|
"cache_read_input_token_cost": 3.3e-7,
|
||||||
"input_cost_per_token": 0.0000033,
|
"input_cost_per_token": 0.0000033,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -767,11 +779,13 @@
|
||||||
},
|
},
|
||||||
"anthropic.claude-sonnet-4-5-20250929-v1:0": {
|
"anthropic.claude-sonnet-4-5-20250929-v1:0": {
|
||||||
"cache_creation_input_token_cost": 0.00000375,
|
"cache_creation_input_token_cost": 0.00000375,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.000006,
|
||||||
"cache_read_input_token_cost": 3e-7,
|
"cache_read_input_token_cost": 3e-7,
|
||||||
"input_cost_per_token": 0.000003,
|
"input_cost_per_token": 0.000003,
|
||||||
"input_cost_per_token_above_200k_tokens": 0.000006,
|
"input_cost_per_token_above_200k_tokens": 0.000006,
|
||||||
"output_cost_per_token_above_200k_tokens": 0.0000225,
|
"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_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,
|
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
"max_input_tokens": 200000,
|
"max_input_tokens": 200000,
|
||||||
|
|
@ -2678,11 +2692,13 @@
|
||||||
},
|
},
|
||||||
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": {
|
"global.anthropic.claude-sonnet-4-5-20250929-v1:0": {
|
||||||
"cache_creation_input_token_cost": 0.00000375,
|
"cache_creation_input_token_cost": 0.00000375,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.000006,
|
||||||
"cache_read_input_token_cost": 3e-7,
|
"cache_read_input_token_cost": 3e-7,
|
||||||
"input_cost_per_token": 0.000003,
|
"input_cost_per_token": 0.000003,
|
||||||
"input_cost_per_token_above_200k_tokens": 0.000006,
|
"input_cost_per_token_above_200k_tokens": 0.000006,
|
||||||
"output_cost_per_token_above_200k_tokens": 0.0000225,
|
"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_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,
|
"cache_read_input_token_cost_above_200k_tokens": 6e-7,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
"max_input_tokens": 200000,
|
"max_input_tokens": 200000,
|
||||||
|
|
@ -2739,6 +2755,7 @@
|
||||||
},
|
},
|
||||||
"global.anthropic.claude-haiku-4-5-20251001-v1:0": {
|
"global.anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||||
"cache_creation_input_token_cost": 0.00000125,
|
"cache_creation_input_token_cost": 0.00000125,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.000002,
|
||||||
"cache_read_input_token_cost": 1e-7,
|
"cache_read_input_token_cost": 1e-7,
|
||||||
"input_cost_per_token": 0.000001,
|
"input_cost_per_token": 0.000001,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -3493,6 +3510,7 @@
|
||||||
},
|
},
|
||||||
"us.anthropic.claude-haiku-4-5-20251001-v1:0": {
|
"us.anthropic.claude-haiku-4-5-20251001-v1:0": {
|
||||||
"cache_creation_input_token_cost": 0.000001375,
|
"cache_creation_input_token_cost": 0.000001375,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.0000022,
|
||||||
"cache_read_input_token_cost": 1.1e-7,
|
"cache_read_input_token_cost": 1.1e-7,
|
||||||
"input_cost_per_token": 0.0000011,
|
"input_cost_per_token": 0.0000011,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -3644,11 +3662,13 @@
|
||||||
},
|
},
|
||||||
"us.anthropic.claude-sonnet-4-5-20250929-v1:0": {
|
"us.anthropic.claude-sonnet-4-5-20250929-v1:0": {
|
||||||
"cache_creation_input_token_cost": 0.000004125,
|
"cache_creation_input_token_cost": 0.000004125,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.0000066,
|
||||||
"cache_read_input_token_cost": 3.3e-7,
|
"cache_read_input_token_cost": 3.3e-7,
|
||||||
"input_cost_per_token": 0.0000033,
|
"input_cost_per_token": 0.0000033,
|
||||||
"input_cost_per_token_above_200k_tokens": 0.0000066,
|
"input_cost_per_token_above_200k_tokens": 0.0000066,
|
||||||
"output_cost_per_token_above_200k_tokens": 0.00002475,
|
"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_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,
|
"cache_read_input_token_cost_above_200k_tokens": 6.6e-7,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
"max_input_tokens": 200000,
|
"max_input_tokens": 200000,
|
||||||
|
|
@ -3749,6 +3769,7 @@
|
||||||
},
|
},
|
||||||
"us.anthropic.claude-opus-4-5-20251101-v1:0": {
|
"us.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||||
"cache_creation_input_token_cost": 0.000006875,
|
"cache_creation_input_token_cost": 0.000006875,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.000011,
|
||||||
"cache_read_input_token_cost": 5.5e-7,
|
"cache_read_input_token_cost": 5.5e-7,
|
||||||
"input_cost_per_token": 0.0000055,
|
"input_cost_per_token": 0.0000055,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
@ -3776,6 +3797,7 @@
|
||||||
},
|
},
|
||||||
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
|
"global.anthropic.claude-opus-4-5-20251101-v1:0": {
|
||||||
"cache_creation_input_token_cost": 0.00000625,
|
"cache_creation_input_token_cost": 0.00000625,
|
||||||
|
"cache_creation_input_token_cost_above_1hr": 0.00001,
|
||||||
"cache_read_input_token_cost": 5e-7,
|
"cache_read_input_token_cost": 5e-7,
|
||||||
"input_cost_per_token": 0.000005,
|
"input_cost_per_token": 0.000005,
|
||||||
"litellm_provider": "bedrock_converse",
|
"litellm_provider": "bedrock_converse",
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import type { MemberWorkSyncOutboxItem } from '../../contracts';
|
|
||||||
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
|
import { appendMemberWorkSyncAudit, reasonToAuditEvent } from './MemberWorkSyncAudit';
|
||||||
|
|
||||||
|
import type { MemberWorkSyncOutboxItem } from '../../contracts';
|
||||||
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
|
import type { MemberWorkSyncAuditEventName, MemberWorkSyncUseCaseDeps } from './ports';
|
||||||
|
|
||||||
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
const MEMBER_WORK_SYNC_MAX_NUDGES_PER_MEMBER_PER_HOUR = 2;
|
||||||
|
|
|
||||||
|
|
@ -1,10 +1,11 @@
|
||||||
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
import { appendMemberWorkSyncAudit } from './MemberWorkSyncAudit';
|
||||||
|
|
||||||
|
import type { RuntimeTurnSettledEvent } from '../domain';
|
||||||
import type {
|
import type {
|
||||||
MemberWorkSyncAuditJournalPort,
|
MemberWorkSyncAuditJournalPort,
|
||||||
MemberWorkSyncClockPort,
|
MemberWorkSyncClockPort,
|
||||||
MemberWorkSyncLoggerPort,
|
MemberWorkSyncLoggerPort,
|
||||||
} from './ports';
|
} from './ports';
|
||||||
import type { RuntimeTurnSettledEvent } from '../domain';
|
|
||||||
import type {
|
import type {
|
||||||
RuntimeTurnSettledEventStorePort,
|
RuntimeTurnSettledEventStorePort,
|
||||||
RuntimeTurnSettledPayloadNormalizerPort,
|
RuntimeTurnSettledPayloadNormalizerPort,
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
|
export * from './MemberWorkSyncAudit';
|
||||||
export * from './MemberWorkSyncDiagnosticsReader';
|
export * from './MemberWorkSyncDiagnosticsReader';
|
||||||
export * from './MemberWorkSyncMetricsReader';
|
export * from './MemberWorkSyncMetricsReader';
|
||||||
export * from './MemberWorkSyncAudit';
|
|
||||||
export * from './MemberWorkSyncNudgeDispatcher';
|
export * from './MemberWorkSyncNudgeDispatcher';
|
||||||
export * from './MemberWorkSyncNudgeOutboxPlanner';
|
export * from './MemberWorkSyncNudgeOutboxPlanner';
|
||||||
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
export * from './MemberWorkSyncPendingReportIntentReplayer';
|
||||||
|
|
|
||||||
|
|
@ -142,11 +142,10 @@ export class MemberWorkSyncTeamChangeRouter {
|
||||||
runAfterMs?: number
|
runAfterMs?: number
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
const activeMembers = await this.rosterSource.loadActiveMemberNames(teamName);
|
const activeMembers = await this.rosterSource.loadActiveMemberNames(teamName);
|
||||||
if (this.materializer) {
|
const materializer = this.materializer;
|
||||||
|
if (materializer) {
|
||||||
await Promise.allSettled(
|
await Promise.allSettled(
|
||||||
activeMembers.map((memberName) =>
|
activeMembers.map((memberName) => materializer.materializeMember(teamName, memberName))
|
||||||
this.materializer?.materializeMember(teamName, memberName)
|
|
||||||
)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
for (const memberName of activeMembers) {
|
for (const memberName of activeMembers) {
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ import { TeamTaskStallJournalWorkSyncCooldown } from '../adapters/output/TeamTas
|
||||||
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
|
import { ClaudeStopHookPayloadNormalizer } from '../infrastructure/ClaudeStopHookPayloadNormalizer';
|
||||||
import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer';
|
import { CodexNativeTurnSettledPayloadNormalizer } from '../infrastructure/CodexNativeTurnSettledPayloadNormalizer';
|
||||||
import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer';
|
import { CompositeRuntimeTurnSettledPayloadNormalizer } from '../infrastructure/CompositeRuntimeTurnSettledPayloadNormalizer';
|
||||||
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
|
|
||||||
import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal';
|
import { FileMemberWorkSyncAuditJournal } from '../infrastructure/FileMemberWorkSyncAuditJournal';
|
||||||
|
import { FileRuntimeTurnSettledEventStore } from '../infrastructure/FileRuntimeTurnSettledEventStore';
|
||||||
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
|
import { HmacMemberWorkSyncReportTokenAdapter } from '../infrastructure/HmacMemberWorkSyncReportTokenAdapter';
|
||||||
import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore';
|
import { JsonMemberWorkSyncStore } from '../infrastructure/JsonMemberWorkSyncStore';
|
||||||
import {
|
import {
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
|
import { withFileLock } from '@main/services/team/fileLock';
|
||||||
import { appendFile, mkdir, rename, rm, stat } from 'fs/promises';
|
import { appendFile, mkdir, rename, rm, stat } from 'fs/promises';
|
||||||
import { dirname } from 'path';
|
import { dirname } from 'path';
|
||||||
|
|
||||||
import { withFileLock } from '@main/services/team/fileLock';
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MemberWorkSyncAuditEvent,
|
MemberWorkSyncAuditEvent,
|
||||||
MemberWorkSyncAuditJournalPort,
|
MemberWorkSyncAuditJournalPort,
|
||||||
|
|
|
||||||
|
|
@ -2,13 +2,14 @@ import { mkdir, readdir, readFile, rename, rm, stat, writeFile } from 'fs/promis
|
||||||
import path from 'path';
|
import path from 'path';
|
||||||
|
|
||||||
import { isRuntimeTurnSettledProvider } from '../../core/domain';
|
import { isRuntimeTurnSettledProvider } from '../../core/domain';
|
||||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
|
||||||
import type {
|
import type {
|
||||||
RuntimeTurnSettledClaimedPayload,
|
RuntimeTurnSettledClaimedPayload,
|
||||||
RuntimeTurnSettledEventStorePort,
|
RuntimeTurnSettledEventStorePort,
|
||||||
RuntimeTurnSettledInvalidResult,
|
RuntimeTurnSettledInvalidResult,
|
||||||
RuntimeTurnSettledProcessedResult,
|
RuntimeTurnSettledProcessedResult,
|
||||||
} from '../../core/application';
|
} from '../../core/application';
|
||||||
|
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||||
import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
|
import type { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
|
||||||
|
|
||||||
const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;
|
const DEFAULT_MAX_PAYLOAD_BYTES = 256 * 1024;
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import { join } from 'path';
|
|
||||||
|
|
||||||
import { TeamMemberStoragePaths } from '@main/services/team/TeamMemberStoragePaths';
|
import { TeamMemberStoragePaths } from '@main/services/team/TeamMemberStoragePaths';
|
||||||
|
import { join } from 'path';
|
||||||
|
|
||||||
export class MemberWorkSyncStorePaths {
|
export class MemberWorkSyncStorePaths {
|
||||||
private readonly memberStorage: TeamMemberStoragePaths;
|
private readonly memberStorage: TeamMemberStoragePaths;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import {
|
||||||
buildRuntimeTurnSettledSourceId,
|
buildRuntimeTurnSettledSourceId,
|
||||||
type RuntimeTurnSettledProvider,
|
type RuntimeTurnSettledProvider,
|
||||||
} from '../../core/domain';
|
} from '../../core/domain';
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
MemberWorkSyncHashPort,
|
MemberWorkSyncHashPort,
|
||||||
RuntimeTurnSettledPayloadNormalization,
|
RuntimeTurnSettledPayloadNormalization,
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { ShellRuntimeTurnSettledHookScriptInstaller } from './ShellRuntimeTurnSettledHookScriptInstaller';
|
|
||||||
import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
|
|
||||||
import { buildRuntimeTurnSettledEnvironment } from './runtimeTurnSettledEnvironment';
|
import { buildRuntimeTurnSettledEnvironment } from './runtimeTurnSettledEnvironment';
|
||||||
import { buildRuntimeTurnSettledHookSettings } from './runtimeTurnSettledHookSettings';
|
import { buildRuntimeTurnSettledHookSettings } from './runtimeTurnSettledHookSettings';
|
||||||
|
import { RuntimeTurnSettledSpoolPaths } from './RuntimeTurnSettledSpoolPaths';
|
||||||
|
import { ShellRuntimeTurnSettledHookScriptInstaller } from './ShellRuntimeTurnSettledHookScriptInstaller';
|
||||||
|
|
||||||
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
import type { RuntimeTurnSettledProvider } from '../../core/domain';
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -47,11 +47,19 @@ export class TeamInboxWriter {
|
||||||
...(request.slashCommand && { slashCommand: request.slashCommand }),
|
...(request.slashCommand && { slashCommand: request.slashCommand }),
|
||||||
...(request.commandOutput && { commandOutput: request.commandOutput }),
|
...(request.commandOutput && { commandOutput: request.commandOutput }),
|
||||||
};
|
};
|
||||||
|
let resultMessageId = messageId;
|
||||||
|
let resultDeduplicated = false;
|
||||||
|
|
||||||
await withFileLock(inboxPath, async () => {
|
await withFileLock(inboxPath, async () => {
|
||||||
await withInboxLock(inboxPath, async () => {
|
await withInboxLock(inboxPath, async () => {
|
||||||
for (let attempt = 0; attempt < 3; attempt++) {
|
for (let attempt = 0; attempt < 3; attempt++) {
|
||||||
const list = await this.readInbox(inboxPath);
|
const list = await this.readInbox(inboxPath);
|
||||||
|
const duplicate = this.findRuntimeDeliveryDuplicate(list, payload);
|
||||||
|
if (duplicate) {
|
||||||
|
resultMessageId = duplicate.messageId ?? messageId;
|
||||||
|
resultDeduplicated = true;
|
||||||
|
return;
|
||||||
|
}
|
||||||
list.push(payload);
|
list.push(payload);
|
||||||
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
|
await atomicWriteAsync(inboxPath, JSON.stringify(list, null, 2));
|
||||||
const written = await this.readInbox(inboxPath);
|
const written = await this.readInbox(inboxPath);
|
||||||
|
|
@ -66,10 +74,56 @@ export class TeamInboxWriter {
|
||||||
|
|
||||||
return {
|
return {
|
||||||
deliveredToInbox: true,
|
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[]> {
|
private async readInbox(inboxPath: string): Promise<InboxMessage[]> {
|
||||||
let raw: string;
|
let raw: string;
|
||||||
try {
|
try {
|
||||||
|
|
|
||||||
|
|
@ -122,6 +122,67 @@ function normalizeOptionalString(value: unknown): string | undefined {
|
||||||
return typeof value === 'string' && value.trim().length > 0 ? value.trim() : 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 {
|
function normalizeMemberName(name: string): string {
|
||||||
return name.trim();
|
return name.trim();
|
||||||
}
|
}
|
||||||
|
|
@ -148,8 +209,8 @@ function buildDiagnostics(
|
||||||
} else if (member.runtimeAlive && !member.bootstrapConfirmed) {
|
} else if (member.runtimeAlive && !member.bootstrapConfirmed) {
|
||||||
diagnostics.push('waiting for teammate check-in');
|
diagnostics.push('waiting for teammate check-in');
|
||||||
}
|
}
|
||||||
if (member.hardFailureReason)
|
const hardFailureReason = normalizeLaunchFailureReasonText(member.hardFailureReason);
|
||||||
diagnostics.push(`hard failure reason: ${member.hardFailureReason}`);
|
if (hardFailureReason) diagnostics.push(`hard failure reason: ${hardFailureReason}`);
|
||||||
if (member.skippedForLaunch) {
|
if (member.skippedForLaunch) {
|
||||||
diagnostics.push(
|
diagnostics.push(
|
||||||
member.skipReason
|
member.skipReason
|
||||||
|
|
@ -473,9 +534,7 @@ function normalizePersistedMemberState(
|
||||||
hardFailure,
|
hardFailure,
|
||||||
hardFailureReason: !hardFailure
|
hardFailureReason: !hardFailure
|
||||||
? undefined
|
? undefined
|
||||||
: typeof parsed.hardFailureReason === 'string' && parsed.hardFailureReason.trim().length > 0
|
: normalizeLaunchFailureReasonText(parsed.hardFailureReason),
|
||||||
? parsed.hardFailureReason.trim()
|
|
||||||
: undefined,
|
|
||||||
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
|
pendingPermissionRequestIds: normalizePendingPermissionRequestIds(
|
||||||
parsed.pendingPermissionRequestIds
|
parsed.pendingPermissionRequestIds
|
||||||
),
|
),
|
||||||
|
|
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
|
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
||||||
import { mkdir, readFile } from 'fs/promises';
|
import { mkdir, readFile } from 'fs/promises';
|
||||||
import { join } from 'path';
|
import { join } from 'path';
|
||||||
|
|
||||||
import { atomicWriteAsync } from '@main/utils/atomicWrite';
|
|
||||||
|
|
||||||
export interface TeamMemberStorageMetaFile {
|
export interface TeamMemberStorageMetaFile {
|
||||||
schemaVersion: 1;
|
schemaVersion: 1;
|
||||||
memberName: string;
|
memberName: string;
|
||||||
|
|
|
||||||
|
|
@ -168,8 +168,8 @@ import {
|
||||||
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
} from './opencode/store/OpenCodeRuntimeManifestEvidenceReader';
|
||||||
import {
|
import {
|
||||||
createRuntimeRunTombstoneStore,
|
createRuntimeRunTombstoneStore,
|
||||||
RuntimeStaleEvidenceError,
|
|
||||||
type RuntimeEvidenceKind,
|
type RuntimeEvidenceKind,
|
||||||
|
RuntimeStaleEvidenceError,
|
||||||
} from './opencode/store/RuntimeRunTombstoneStore';
|
} from './opencode/store/RuntimeRunTombstoneStore';
|
||||||
import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore';
|
import { OpenCodeTaskLogAttributionStore } from './taskLogs/stream/OpenCodeTaskLogAttributionStore';
|
||||||
import { buildActionModeProtocol } from './actionModeInstructions';
|
import { buildActionModeProtocol } from './actionModeInstructions';
|
||||||
|
|
@ -210,6 +210,7 @@ import {
|
||||||
createPersistedLaunchSnapshot,
|
createPersistedLaunchSnapshot,
|
||||||
deriveTeamLaunchAggregateState,
|
deriveTeamLaunchAggregateState,
|
||||||
hasMixedPersistedLaunchMetadata,
|
hasMixedPersistedLaunchMetadata,
|
||||||
|
normalizeLaunchFailureReasonText,
|
||||||
snapshotFromRuntimeMemberStatuses,
|
snapshotFromRuntimeMemberStatuses,
|
||||||
snapshotToMemberSpawnStatuses,
|
snapshotToMemberSpawnStatuses,
|
||||||
} from './TeamLaunchStateEvaluator';
|
} from './TeamLaunchStateEvaluator';
|
||||||
|
|
@ -1626,7 +1627,7 @@ interface PromptSizeSummary {
|
||||||
lines: number;
|
lines: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const MEMBER_LAUNCH_GRACE_MS = 150_000;
|
const MEMBER_LAUNCH_GRACE_MS = 120_000;
|
||||||
const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000;
|
const MEMBER_BOOTSTRAP_STALL_MS = 5 * 60_000;
|
||||||
|
|
||||||
export function shouldWarnOnUnreadableMemberAuditConfig(params: {
|
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(
|
function hasOpenCodeRuntimeHandle(
|
||||||
value:
|
value:
|
||||||
| Pick<PersistedTeamLaunchMemberState, 'runtimePid' | 'runtimeSessionId' | 'livenessKind'>
|
| Pick<PersistedTeamLaunchMemberState, 'runtimePid' | 'runtimeSessionId' | 'livenessKind'>
|
||||||
|
|
@ -2518,7 +2580,7 @@ function extractHeartbeatTimestamp(text: string, fallback?: string): string | un
|
||||||
}
|
}
|
||||||
|
|
||||||
function extractBootstrapFailureReason(text: string): string | null {
|
function extractBootstrapFailureReason(text: string): string | null {
|
||||||
const trimmed = text.trim();
|
const trimmed = normalizeLaunchFailureReasonText(text) ?? text.trim();
|
||||||
if (!trimmed) return null;
|
if (!trimmed) return null;
|
||||||
if (isBootstrapInstructionPrompt(trimmed)) return null;
|
if (isBootstrapInstructionPrompt(trimmed)) return null;
|
||||||
const lower = trimmed.toLowerCase();
|
const lower = trimmed.toLowerCase();
|
||||||
|
|
@ -6054,9 +6116,14 @@ export class TeamProvisioningService {
|
||||||
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
return { delivered: false, reason: 'opencode_runtime_not_active' };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
const inMemorySecondaryLaneRunId =
|
||||||
|
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
|
||||||
|
? this.getCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)
|
||||||
|
: null;
|
||||||
let runtimeRunId =
|
let runtimeRunId =
|
||||||
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
|
laneIdentity.laneKind === 'secondary' && laneIdentity.laneOwnerProviderId === 'opencode'
|
||||||
? (liveSecondaryLaneRunId ??
|
? (liveSecondaryLaneRunId ??
|
||||||
|
inMemorySecondaryLaneRunId ??
|
||||||
(await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)))
|
(await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)))
|
||||||
: (trackedRunId ??
|
: (trackedRunId ??
|
||||||
(await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)));
|
(await this.resolveCurrentOpenCodeRuntimeRunId(teamName, laneIdentity.laneId)));
|
||||||
|
|
@ -6102,8 +6169,11 @@ export class TeamProvisioningService {
|
||||||
}
|
}
|
||||||
if (
|
if (
|
||||||
runtimeActive &&
|
runtimeActive &&
|
||||||
|
runtimeRunId &&
|
||||||
laneIdentity.laneKind === 'secondary' &&
|
laneIdentity.laneKind === 'secondary' &&
|
||||||
laneIdentity.laneOwnerProviderId === 'opencode'
|
laneIdentity.laneOwnerProviderId === 'opencode' &&
|
||||||
|
!liveSecondaryLaneRunId &&
|
||||||
|
!inMemorySecondaryLaneRunId
|
||||||
) {
|
) {
|
||||||
const laneStorage = await inspectOpenCodeRuntimeLaneStorage({
|
const laneStorage = await inspectOpenCodeRuntimeLaneStorage({
|
||||||
teamsBasePath: getTeamsBasePath(),
|
teamsBasePath: getTeamsBasePath(),
|
||||||
|
|
@ -17556,6 +17626,64 @@ export class TeamProvisioningService {
|
||||||
this.emitMemberSpawnChange(run, lane.member.name);
|
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(
|
private buildMixedPersistedLaunchSnapshotForRun(
|
||||||
run: ProvisioningRun,
|
run: ProvisioningRun,
|
||||||
launchPhase: PersistedTeamLaunchPhase
|
launchPhase: PersistedTeamLaunchPhase
|
||||||
|
|
@ -17899,7 +18027,7 @@ export class TeamProvisioningService {
|
||||||
laneId: lane.laneId,
|
laneId: lane.laneId,
|
||||||
runId: lane.runId,
|
runId: lane.runId,
|
||||||
});
|
});
|
||||||
const result = await adapter.launch({
|
const rawResult = await adapter.launch({
|
||||||
runId: lane.runId,
|
runId: lane.runId,
|
||||||
laneId: lane.laneId,
|
laneId: lane.laneId,
|
||||||
teamName: run.teamName,
|
teamName: run.teamName,
|
||||||
|
|
@ -17923,6 +18051,12 @@ export class TeamProvisioningService {
|
||||||
],
|
],
|
||||||
previousLaunchState,
|
previousLaunchState,
|
||||||
});
|
});
|
||||||
|
const result = await this.guardCommittedOpenCodeSecondaryLaneEvidence({
|
||||||
|
teamName: run.teamName,
|
||||||
|
laneId: lane.laneId,
|
||||||
|
memberName: lane.member.name,
|
||||||
|
result: rawResult,
|
||||||
|
});
|
||||||
if (run.cancelRequested || run.processKilled) {
|
if (run.cancelRequested || run.processKilled) {
|
||||||
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
|
this.deleteSecondaryRuntimeRun(run.teamName, lane.laneId);
|
||||||
return;
|
return;
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
buildMemberAvatarMap,
|
buildMemberAvatarMap,
|
||||||
buildMemberLaunchPresentation,
|
buildMemberLaunchPresentation,
|
||||||
displayMemberName,
|
displayMemberName,
|
||||||
|
isOpenCodeRelaunchActionable,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
import {
|
import {
|
||||||
buildMemberLaunchDiagnosticsPayload,
|
buildMemberLaunchDiagnosticsPayload,
|
||||||
|
|
@ -241,11 +242,20 @@ export const MemberCard = ({
|
||||||
const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch;
|
const showSkippedLaunchBadge = !isRemoved && isSkippedLaunch;
|
||||||
const hasLiveLaunchControls =
|
const hasLiveLaunchControls =
|
||||||
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
|
isTeamAlive === true || isTeamProvisioning === true || isLaunchSettling === true;
|
||||||
const canRetryLaunch =
|
const hasRestartMemberControl =
|
||||||
(showFailedLaunchBadge || showSkippedLaunchBadge) &&
|
!isRemoved &&
|
||||||
!isLeadMember(member) &&
|
!isLeadMember(member) &&
|
||||||
Boolean(onRestartMember) &&
|
Boolean(onRestartMember) &&
|
||||||
hasLiveLaunchControls;
|
hasLiveLaunchControls &&
|
||||||
|
runtimeEntry?.restartable !== false;
|
||||||
|
const openCodeRelaunchActionable = isOpenCodeRelaunchActionable({
|
||||||
|
member,
|
||||||
|
spawnEntry,
|
||||||
|
runtimeEntry,
|
||||||
|
});
|
||||||
|
const canRelaunchOpenCode = hasRestartMemberControl && openCodeRelaunchActionable;
|
||||||
|
const canRetryLaunch =
|
||||||
|
(showFailedLaunchBadge || showSkippedLaunchBadge) && hasRestartMemberControl;
|
||||||
const canSkipFailedLaunch =
|
const canSkipFailedLaunch =
|
||||||
showFailedLaunchBadge &&
|
showFailedLaunchBadge &&
|
||||||
!isLeadMember(member) &&
|
!isLeadMember(member) &&
|
||||||
|
|
@ -258,9 +268,14 @@ export const MemberCard = ({
|
||||||
!isFailedLaunch &&
|
!isFailedLaunch &&
|
||||||
!isSkippedLaunch &&
|
!isSkippedLaunch &&
|
||||||
(Boolean(activityTask) || !isAwaitingReply);
|
(Boolean(activityTask) || !isAwaitingReply);
|
||||||
const handleRetryFailedLaunch = async (
|
const restartActionIdleLabel = canRelaunchOpenCode ? 'Relaunch OpenCode' : 'Retry teammate';
|
||||||
event: React.MouseEvent<HTMLButtonElement>
|
const restartActionBusyLabel = canRelaunchOpenCode
|
||||||
): Promise<void> => {
|
? '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.preventDefault();
|
||||||
event.stopPropagation();
|
event.stopPropagation();
|
||||||
if (!onRestartMember || retryingLaunch) {
|
if (!onRestartMember || retryingLaunch) {
|
||||||
|
|
@ -271,7 +286,7 @@ export const MemberCard = ({
|
||||||
try {
|
try {
|
||||||
await onRestartMember(member.name);
|
await onRestartMember(member.name);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
setRetryLaunchError(error instanceof Error ? error.message : 'Failed to retry teammate');
|
setRetryLaunchError(error instanceof Error ? error.message : restartActionErrorFallback);
|
||||||
} finally {
|
} finally {
|
||||||
setRetryingLaunch(false);
|
setRetryingLaunch(false);
|
||||||
}
|
}
|
||||||
|
|
@ -448,6 +463,29 @@ export const MemberCard = ({
|
||||||
>
|
>
|
||||||
{launchBadgeLabel}
|
{launchBadgeLabel}
|
||||||
</Badge>
|
</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>
|
</span>
|
||||||
) : showFailedLaunchBadge ? (
|
) : showFailedLaunchBadge ? (
|
||||||
<span className="flex shrink-0 items-center gap-1">
|
<span className="flex shrink-0 items-center gap-1">
|
||||||
|
|
@ -499,10 +537,10 @@ export const MemberCard = ({
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
disabled={retryingLaunch || skippingLaunch}
|
||||||
onClick={handleRetryFailedLaunch}
|
onClick={handleRestartMember}
|
||||||
>
|
>
|
||||||
{retryingLaunch ? (
|
{retryingLaunch ? (
|
||||||
<SyncedLoader2 className="size-3.5" />
|
<SyncedLoader2 className="size-3.5" />
|
||||||
|
|
@ -513,7 +551,7 @@ export const MemberCard = ({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
{retryLaunchError ??
|
{retryLaunchError ??
|
||||||
(retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')}
|
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
@ -541,10 +579,10 @@ export const MemberCard = ({
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}
|
disabled={retryingLaunch}
|
||||||
onClick={handleRetryFailedLaunch}
|
onClick={handleRestartMember}
|
||||||
>
|
>
|
||||||
{retryingLaunch ? (
|
{retryingLaunch ? (
|
||||||
<SyncedLoader2 className="size-3.5" />
|
<SyncedLoader2 className="size-3.5" />
|
||||||
|
|
@ -555,7 +593,7 @@ export const MemberCard = ({
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent side="bottom">
|
<TooltipContent side="bottom">
|
||||||
{retryLaunchError ??
|
{retryLaunchError ??
|
||||||
(retryingLaunch ? 'Retrying teammate...' : 'Retry teammate')}
|
(retryingLaunch ? `${restartActionBusyLabel}...` : restartActionIdleLabel)}
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import {
|
||||||
hasMemberLaunchDiagnosticsDetails,
|
hasMemberLaunchDiagnosticsDetails,
|
||||||
hasMemberLaunchDiagnosticsError,
|
hasMemberLaunchDiagnosticsError,
|
||||||
} from '@renderer/utils/memberLaunchDiagnostics';
|
} from '@renderer/utils/memberLaunchDiagnostics';
|
||||||
|
import { isOpenCodeRelaunchActionable } from '@renderer/utils/memberHelpers';
|
||||||
import {
|
import {
|
||||||
getRuntimeMemorySourceLabel,
|
getRuntimeMemorySourceLabel,
|
||||||
resolveMemberRuntimeSummary,
|
resolveMemberRuntimeSummary,
|
||||||
|
|
@ -173,10 +174,14 @@ export const MemberDetailDialog = ({
|
||||||
[launchParams, member, runtimeEntry, spawnEntry]
|
[launchParams, member, runtimeEntry, spawnEntry]
|
||||||
);
|
);
|
||||||
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
const memorySourceLabel = getRuntimeMemorySourceLabel(runtimeEntry);
|
||||||
|
const openCodeRelaunchActionable = member
|
||||||
|
? isOpenCodeRelaunchActionable({ member, spawnEntry, runtimeEntry })
|
||||||
|
: false;
|
||||||
const restartInFlight =
|
const restartInFlight =
|
||||||
spawnEntry?.launchState === 'starting' ||
|
!openCodeRelaunchActionable &&
|
||||||
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
|
(spawnEntry?.launchState === 'starting' ||
|
||||||
spawnEntry?.launchState === 'runtime_pending_permission';
|
spawnEntry?.launchState === 'runtime_pending_bootstrap' ||
|
||||||
|
spawnEntry?.launchState === 'runtime_pending_permission');
|
||||||
const launchDiagnosticsPayload = useMemo(
|
const launchDiagnosticsPayload = useMemo(
|
||||||
() =>
|
() =>
|
||||||
member
|
member
|
||||||
|
|
@ -203,7 +208,8 @@ export const MemberDetailDialog = ({
|
||||||
const effectiveLaunchErrorMessage = openCodeNoRuntimeEvidence
|
const effectiveLaunchErrorMessage = openCodeNoRuntimeEvidence
|
||||||
? OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE
|
? OPENCODE_NO_RUNTIME_EVIDENCE_MESSAGE
|
||||||
: launchErrorMessage;
|
: launchErrorMessage;
|
||||||
const restartButtonLabel = openCodeNoRuntimeEvidence ? 'Relaunch OpenCode' : 'Restart';
|
const restartButtonLabel =
|
||||||
|
openCodeNoRuntimeEvidence || openCodeRelaunchActionable ? 'Relaunch OpenCode' : 'Restart';
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!open || !member) {
|
if (!open || !member) {
|
||||||
|
|
|
||||||
|
|
@ -229,6 +229,15 @@ export function getLaunchJoinMilestonesFromMembers({
|
||||||
liveSummary.failedSpawnCount +
|
liveSummary.failedSpawnCount +
|
||||||
liveSummary.skippedSpawnCount;
|
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 =
|
const liveSummaryIsMoreAdvanced =
|
||||||
liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount ||
|
liveSummary.failedSpawnCount > snapshotMilestones.failedSpawnCount ||
|
||||||
liveSummary.skippedSpawnCount > snapshotMilestones.skippedSpawnCount ||
|
liveSummary.skippedSpawnCount > snapshotMilestones.skippedSpawnCount ||
|
||||||
|
|
@ -239,7 +248,7 @@ export function getLaunchJoinMilestonesFromMembers({
|
||||||
liveSummary.pendingSpawnCount > snapshotMilestones.pendingSpawnCount) ||
|
liveSummary.pendingSpawnCount > snapshotMilestones.pendingSpawnCount) ||
|
||||||
liveAccountedFor > snapshotAccountedFor;
|
liveAccountedFor > snapshotAccountedFor;
|
||||||
|
|
||||||
return liveSummaryIsMoreAdvanced
|
return liveSummaryIsMoreAdvanced || liveSummaryContradictsCleanSnapshot
|
||||||
? {
|
? {
|
||||||
expectedTeammateCount,
|
expectedTeammateCount,
|
||||||
...liveSummary,
|
...liveSummary,
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import type {
|
||||||
MemberRuntimeAdvisory,
|
MemberRuntimeAdvisory,
|
||||||
MemberSpawnLivenessSource,
|
MemberSpawnLivenessSource,
|
||||||
MemberSpawnStatus,
|
MemberSpawnStatus,
|
||||||
|
MemberSpawnStatusEntry,
|
||||||
MemberStatus,
|
MemberStatus,
|
||||||
ResolvedTeamMember,
|
ResolvedTeamMember,
|
||||||
TeamAgentRuntimeEntry,
|
TeamAgentRuntimeEntry,
|
||||||
|
|
@ -131,6 +132,8 @@ export const SPAWN_PRESENCE_LABELS: Record<MemberSpawnStatus, string> = {
|
||||||
skipped: 'skipped',
|
skipped: 'skipped',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const OPENCODE_RUNTIME_CANDIDATE_RELAUNCH_GRACE_MS = 5 * 60 * 1000;
|
||||||
|
|
||||||
function isLaunchStillStarting(
|
function isLaunchStillStarting(
|
||||||
spawnStatus: MemberSpawnStatus | undefined,
|
spawnStatus: MemberSpawnStatus | undefined,
|
||||||
spawnLaunchState: MemberLaunchState | 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({
|
export function buildMemberLaunchPresentation({
|
||||||
member,
|
member,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
|
|
@ -634,7 +741,7 @@ export function buildMemberLaunchPresentation({
|
||||||
isTeamProvisioning,
|
isTeamProvisioning,
|
||||||
leadActivity
|
leadActivity
|
||||||
);
|
);
|
||||||
const dotClass = getSpawnAwareDotClass(
|
const baseDotClass = getSpawnAwareDotClass(
|
||||||
member,
|
member,
|
||||||
spawnStatus,
|
spawnStatus,
|
||||||
spawnLaunchState,
|
spawnLaunchState,
|
||||||
|
|
@ -701,6 +808,7 @@ export function buildMemberLaunchPresentation({
|
||||||
}
|
}
|
||||||
|
|
||||||
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
|
const launchStatusLabel = getMemberLaunchStatusLabel(launchVisualState);
|
||||||
|
const launchVisualStateDotClass = getLaunchVisualStateDotClass(launchVisualState);
|
||||||
const shouldShowLaunchStatusAsPresence =
|
const shouldShowLaunchStatusAsPresence =
|
||||||
launchVisualState === 'permission_pending' ||
|
launchVisualState === 'permission_pending' ||
|
||||||
launchVisualState === 'runtime_pending' ||
|
launchVisualState === 'runtime_pending' ||
|
||||||
|
|
@ -723,7 +831,10 @@ export function buildMemberLaunchPresentation({
|
||||||
|
|
||||||
return {
|
return {
|
||||||
presenceLabel: displayPresenceLabel,
|
presenceLabel: displayPresenceLabel,
|
||||||
dotClass: runtimeAdvisoryTone === 'error' ? STATUS_DOT_COLORS.terminated : dotClass,
|
dotClass:
|
||||||
|
runtimeAdvisoryTone === 'error'
|
||||||
|
? STATUS_DOT_COLORS.terminated
|
||||||
|
: (launchVisualStateDotClass ?? baseDotClass),
|
||||||
cardClass,
|
cardClass,
|
||||||
runtimeAdvisoryLabel,
|
runtimeAdvisoryLabel,
|
||||||
runtimeAdvisoryTitle,
|
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 () => {
|
it('omits source field from payload when not provided in request', async () => {
|
||||||
await writer.sendMessage('my-team', {
|
await writer.sendMessage('my-team', {
|
||||||
member: 'alice',
|
member: 'alice',
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,40 @@
|
||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
normalizeLaunchFailureReasonText,
|
||||||
normalizePersistedLaunchSnapshot,
|
normalizePersistedLaunchSnapshot,
|
||||||
snapshotToMemberSpawnStatuses,
|
snapshotToMemberSpawnStatuses,
|
||||||
summarizePersistedLaunchMembers,
|
summarizePersistedLaunchMembers,
|
||||||
} from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
} from '../../../../src/main/services/team/TeamLaunchStateEvaluator';
|
||||||
|
|
||||||
describe('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', () => {
|
it('keeps member spawn statuses for persisted members even when expectedMembers is stale', () => {
|
||||||
const statuses = snapshotToMemberSpawnStatuses({
|
const statuses = snapshotToMemberSpawnStatuses({
|
||||||
version: 1,
|
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 () => {
|
it('delivers direct messages to OpenCode secondary lanes with the lane run id', async () => {
|
||||||
const svc = new TeamProvisioningService();
|
const svc = new TeamProvisioningService();
|
||||||
const sendMessageToMember = vi.fn(async (input: Record<string, unknown>) => ({
|
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).getTrackedRunId = vi.fn(() => null);
|
||||||
(svc as any).canDeliverToOpenCodeRuntimeForTeam = vi.fn(() => true);
|
(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).resolveCurrentOpenCodeRuntimeRunId = vi.fn(async () => 'opencode-run-bob');
|
||||||
(svc as any).isOpenCodeRuntimeLaneIndexActive = vi.fn(async () => true);
|
(svc as any).isOpenCodeRuntimeLaneIndexActive = vi.fn(async () => true);
|
||||||
(svc as any).configReader = {
|
(svc as any).configReader = {
|
||||||
|
|
@ -8426,9 +8491,31 @@ describe('TeamProvisioningService', () => {
|
||||||
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
|
const adapterLaunch = vi.fn(async (input: Record<string, unknown>) => {
|
||||||
const expectedMembers = input.expectedMembers as Array<{ name: string }>;
|
const expectedMembers = input.expectedMembers as Array<{ name: string }>;
|
||||||
const memberName = expectedMembers[0]?.name ?? 'unknown';
|
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 {
|
return {
|
||||||
runId: String(input.runId),
|
runId,
|
||||||
teamName: String(input.teamName),
|
teamName,
|
||||||
launchPhase: 'finished',
|
launchPhase: 'finished',
|
||||||
teamLaunchState: 'clean_success',
|
teamLaunchState: 'clean_success',
|
||||||
members: {
|
members: {
|
||||||
|
|
@ -9724,6 +9811,75 @@ describe('TeamProvisioningService', () => {
|
||||||
expect(reason).toBeNull();
|
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 () => {
|
it('clears a stale persisted bootstrap-prompt failure when member_briefing later succeeds', async () => {
|
||||||
allowConsoleLogs();
|
allowConsoleLogs();
|
||||||
const teamName = 'zz-unit-bootstrap-stale-prompt-failure';
|
const teamName = 'zz-unit-bootstrap-stale-prompt-failure';
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,7 @@ const skippedSpawnEntry: MemberSpawnStatusEntry = {
|
||||||
describe('MemberCard starting-state visuals', () => {
|
describe('MemberCard starting-state visuals', () => {
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
document.body.innerHTML = '';
|
document.body.innerHTML = '';
|
||||||
|
vi.useRealTimers();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows runtime summary while keeping the starting treatment after provisioning stops', async () => {
|
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 () => {
|
it('renders skip for failed teammate launches', async () => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
const host = document.createElement('div');
|
const host = document.createElement('div');
|
||||||
|
|
|
||||||
|
|
@ -4,59 +4,8 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import { useStore } from '@renderer/store';
|
import { useStore } from '@renderer/store';
|
||||||
|
|
||||||
import type { MemberWorkSyncStatus } from '@features/member-work-sync/contracts';
|
|
||||||
import type { ResolvedTeamMember, TeamTaskWithKanban } from '@shared/types';
|
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', () => ({
|
vi.mock('@renderer/hooks/useMemberStats', () => ({
|
||||||
useMemberStats: () => ({
|
useMemberStats: () => ({
|
||||||
stats: null,
|
stats: null,
|
||||||
|
|
@ -166,7 +115,6 @@ import { MemberDetailDialog } from '@renderer/components/team/members/MemberDeta
|
||||||
describe('MemberDetailDialog activity count', () => {
|
describe('MemberDetailDialog activity count', () => {
|
||||||
beforeEach(() => {
|
beforeEach(() => {
|
||||||
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
|
||||||
apiMocks.getMemberWorkSyncStatus.mockResolvedValue(makeMemberWorkSyncStatus());
|
|
||||||
useStore.setState({
|
useStore.setState({
|
||||||
teamMessagesByName: {
|
teamMessagesByName: {
|
||||||
'demo-team': {
|
'demo-team': {
|
||||||
|
|
@ -255,12 +203,6 @@ describe('MemberDetailDialog activity count', () => {
|
||||||
|
|
||||||
expect(host.textContent).toContain('activity-count:1');
|
expect(host.textContent).toContain('activity-count:1');
|
||||||
expect(host.textContent).toContain('Activity1');
|
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 () => {
|
await act(async () => {
|
||||||
root.unmount();
|
root.unmount();
|
||||||
|
|
|
||||||
|
|
@ -125,4 +125,54 @@ describe('getLaunchJoinMilestonesFromMembers', () => {
|
||||||
expect(milestones.failedSpawnCount).toBe(0);
|
expect(milestones.failedSpawnCount).toBe(0);
|
||||||
expect(milestones.pendingSpawnCount).toBe(3);
|
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,
|
getSpawnCardClass,
|
||||||
getMemberRuntimeAdvisoryLabel,
|
getMemberRuntimeAdvisoryLabel,
|
||||||
getMemberRuntimeAdvisoryTitle,
|
getMemberRuntimeAdvisoryTitle,
|
||||||
|
isOpenCodeRelaunchActionable,
|
||||||
} from '@renderer/utils/memberHelpers';
|
} from '@renderer/utils/memberHelpers';
|
||||||
|
|
||||||
import type { ResolvedTeamMember } from '@shared/types';
|
import type { ResolvedTeamMember } from '@shared/types';
|
||||||
|
|
@ -280,7 +281,152 @@ describe('memberHelpers spawn-aware presence', () => {
|
||||||
presenceLabel: 'bootstrap unconfirmed',
|
presenceLabel: 'bootstrap unconfirmed',
|
||||||
launchVisualState: 'runtime_candidate',
|
launchVisualState: 'runtime_candidate',
|
||||||
launchStatusLabel: 'bootstrap unconfirmed',
|
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', () => {
|
it('returns shared launch status labels without changing generic presence labels', () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue