Skip to content

Commit b16b452

Browse files
committed
chore: merge dev into hosted web branch
2 parents 695b1a5 + d06548d commit b16b452

55 files changed

Lines changed: 3605 additions & 138 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

scripts/ci/source-file-size-baseline.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -128,7 +128,7 @@
128128
"src/renderer/components/team/messages/MessagesPanel.tsx": 1722,
129129
"src/renderer/components/team/ProvisioningProgressBlock.tsx": 862,
130130
"src/renderer/components/team/review/CodeMirrorDiffView.tsx": 975,
131-
"src/renderer/components/team/TeamDetailView.tsx": 3838,
131+
"src/renderer/components/team/TeamDetailView.tsx": 3831,
132132
"src/renderer/components/team/TeamListView.tsx": 1568,
133133
"src/renderer/components/team/useTeamChangesSummaries.ts": 826,
134134
"src/renderer/components/ui/MentionableTextarea.tsx": 1433,

scripts/ci/source-file-size-legacy.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -125,7 +125,7 @@
125125
"src/renderer/components/team/messages/MessagesPanel.tsx": 1722,
126126
"src/renderer/components/team/ProvisioningProgressBlock.tsx": 862,
127127
"src/renderer/components/team/review/CodeMirrorDiffView.tsx": 975,
128-
"src/renderer/components/team/TeamDetailView.tsx": 3838,
128+
"src/renderer/components/team/TeamDetailView.tsx": 3831,
129129
"src/renderer/components/team/TeamListView.tsx": 1568,
130130
"src/renderer/components/team/useTeamChangesSummaries.ts": 826,
131131
"src/renderer/components/ui/MentionableTextarea.tsx": 1433,

src/features/localization/renderer/locales/en/team.json

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -238,6 +238,7 @@
238238
"description": "Description",
239239
"name": "Name"
240240
},
241+
"leadRestartWarning": "Saving will restart the lead to apply model or effort changes. Teammate processes will keep running.",
241242
"memberRestartWarning": "Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes.",
242243
"notices": {
243244
"liveRenameBlocked": "Live save is blocked because existing teammates were renamed. Revert those identity changes or stop the team first.",

src/features/localization/renderer/resources.d.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3864,6 +3864,7 @@ export default interface Resources {
38643864
description: 'Description';
38653865
name: 'Name';
38663866
};
3867+
leadRestartWarning: 'Saving will restart the lead to apply model or effort changes. Teammate processes will keep running.';
38673868
memberRestartWarning: 'Saving will restart this teammate to apply role, workflow, worktree isolation, provider, model, effort, or MCP access changes.';
38683869
notices: {
38693870
liveRenameBlocked: 'Live save is blocked because existing teammates were renamed. Revert those identity changes or stop the team first.';

src/features/team-provisioning/contracts/memberSettings.ts

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -47,19 +47,29 @@ export type UpdateMemberSettingsEffect =
4747
| 'no_changes'
4848
| 'persisted_only'
4949
| 'member_restart_started'
50+
| 'lead_restart_started'
51+
| 'lead_restart_rolled_back'
5052
| 'opencode_lane_restart_started'
5153
| 'team_relaunch_required'
5254
| 'recovery_required';
5355

54-
export interface UpdateMemberSettingsRequest {
56+
interface UpdateMemberSettingsRequestBase {
5557
commandId: string;
5658
idempotencyKey: string;
5759
teamName: string;
5860
memberName: string;
5961
expectedFingerprint: string;
60-
settings: EditableMemberSettings;
6162
}
6263

64+
export type UpdateMemberSettingsRequest = UpdateMemberSettingsRequestBase &
65+
(
66+
| { targetKind: 'member'; settings: EditableMemberSettings }
67+
| {
68+
targetKind: 'lead';
69+
leadRuntime: Pick<EditableMemberSettings, 'model' | 'effort'>;
70+
}
71+
);
72+
6373
export type UpdateMemberSettingsResult =
6474
| {
6575
outcome: 'completed';

src/features/team-provisioning/core/application/ports/UpdateMemberSettingsPorts.ts

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,22 @@ export class MemberSettingsMutationBusyError extends Error {
2525
}
2626
}
2727

28+
export class MemberSettingsLifecycleFailedError extends Error {
29+
constructor(
30+
message: string,
31+
readonly lifecycleRestored: boolean,
32+
readonly cause?: unknown
33+
) {
34+
super(message);
35+
this.name = 'MemberSettingsLifecycleFailedError';
36+
}
37+
}
38+
39+
export type MemberSettingsLifecycleAdmission =
40+
| { outcome: 'ready'; token?: unknown }
41+
| { outcome: 'busy' }
42+
| { outcome: 'relaunch_required' };
43+
2844
export interface MemberSettingsMutationGatePort {
2945
runExclusive<T>(teamName: string, operation: () => Promise<T>): Promise<T>;
3046
}
@@ -57,11 +73,18 @@ export type MemberSettingsLifecycleEffect = Exclude<
5773
>;
5874

5975
export interface MemberSettingsLifecyclePort {
76+
assess(input: {
77+
teamName: string;
78+
before: MemberSettingsTargetSnapshot;
79+
proposed: MemberSettingsTargetSnapshot;
80+
action: Exclude<MemberSettingsLifecycleAction, 'none'>;
81+
}): Promise<MemberSettingsLifecycleAdmission>;
6082
applyEffect(input: {
6183
teamName: string;
6284
before: MemberSettingsTargetSnapshot;
6385
after: MemberSettingsTargetSnapshot;
6486
action: Exclude<MemberSettingsLifecycleAction, 'none'>;
87+
admission: Extract<MemberSettingsLifecycleAdmission, { outcome: 'ready' }>;
6588
}): Promise<MemberSettingsLifecycleEffect>;
6689
restore(input: {
6790
teamName: string;

src/features/team-provisioning/core/application/use-cases/UpdateMemberSettingsUseCase.ts

Lines changed: 77 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,11 @@
11
import {
22
createMemberSettingsFingerprint,
3+
isCanonicalLeadTarget,
34
normalizeEditableMemberSettings,
45
selectMemberSettingsLifecycleAction,
56
} from '../../domain/memberSettingsPolicy';
67
import {
8+
MemberSettingsLifecycleFailedError,
79
MemberSettingsMutationBusyError,
810
MemberSettingsPersistenceFailedError,
911
} from '../ports/UpdateMemberSettingsPorts';
@@ -52,7 +54,22 @@ export class UpdateMemberSettingsUseCase {
5254
};
5355
}
5456

55-
const normalizedSettings = normalizeEditableMemberSettings(request.settings);
57+
const targetIsLead = isCanonicalLeadTarget(current);
58+
if (request.targetKind === 'lead' && !targetIsLead) {
59+
return {
60+
outcome: 'target_conflict',
61+
memberName: current.name,
62+
expectedFingerprint: request.expectedFingerprint,
63+
actualFingerprint,
64+
reason: 'target_changed',
65+
replayed: false,
66+
};
67+
}
68+
const normalizedSettings = normalizeEditableMemberSettings(
69+
request.targetKind === 'lead'
70+
? { ...current.settings, ...request.leadRuntime }
71+
: request.settings
72+
);
5673
const proposed = { ...current, settings: normalizedSettings };
5774
const proposedFingerprint = createMemberSettingsFingerprint(proposed);
5875
if (proposedFingerprint === actualFingerprint) {
@@ -66,7 +83,10 @@ export class UpdateMemberSettingsUseCase {
6683
};
6784
}
6885

69-
const action = selectMemberSettingsLifecycleAction(current, proposed);
86+
const action =
87+
targetIsLead && request.targetKind === 'member'
88+
? 'require_team_relaunch'
89+
: selectMemberSettingsLifecycleAction(current, proposed);
7090
if (action === 'require_team_relaunch') {
7191
return {
7292
outcome: 'completed',
@@ -78,6 +98,34 @@ export class UpdateMemberSettingsUseCase {
7898
};
7999
}
80100

101+
let admission: Awaited<ReturnType<MemberSettingsLifecyclePort['assess']>> | null = null;
102+
if (action !== 'none') {
103+
admission = await this.dependencies.lifecycle.assess({
104+
teamName: request.teamName,
105+
before: current,
106+
proposed,
107+
action,
108+
});
109+
if (admission.outcome === 'busy') {
110+
return {
111+
outcome: 'busy',
112+
teamName: request.teamName,
113+
memberName: current.name,
114+
replayed: false,
115+
};
116+
}
117+
if (admission.outcome === 'relaunch_required') {
118+
return {
119+
outcome: 'completed',
120+
effect: 'team_relaunch_required',
121+
memberName: current.name,
122+
previousFingerprint: actualFingerprint,
123+
currentFingerprint: actualFingerprint,
124+
replayed: false,
125+
};
126+
}
127+
}
128+
81129
let applied;
82130
try {
83131
applied = await this.dependencies.repository.applyTarget({
@@ -137,13 +185,17 @@ export class UpdateMemberSettingsUseCase {
137185
replayed: false,
138186
};
139187
}
188+
if (admission?.outcome !== 'ready') {
189+
throw new Error('Member settings lifecycle admission was not retained');
190+
}
140191

141192
try {
142193
const effect = await this.dependencies.lifecycle.applyEffect({
143194
teamName: request.teamName,
144195
before: current,
145196
after,
146197
action,
198+
admission,
147199
});
148200
return {
149201
outcome: 'completed',
@@ -155,7 +207,8 @@ export class UpdateMemberSettingsUseCase {
155207
};
156208
} catch (error) {
157209
let persistenceRestored = false;
158-
let lifecycleRestored = false;
210+
let lifecycleRestored =
211+
error instanceof MemberSettingsLifecycleFailedError && error.lifecycleRestored;
159212
try {
160213
persistenceRestored = await this.dependencies.repository.restoreTarget({
161214
teamName: request.teamName,
@@ -167,7 +220,7 @@ export class UpdateMemberSettingsUseCase {
167220
} catch {
168221
persistenceRestored = false;
169222
}
170-
if (persistenceRestored) {
223+
if (persistenceRestored && !lifecycleRestored) {
171224
try {
172225
lifecycleRestored = await this.dependencies.lifecycle.restore({
173226
teamName: request.teamName,
@@ -180,6 +233,26 @@ export class UpdateMemberSettingsUseCase {
180233
}
181234
}
182235

236+
if (
237+
persistenceRestored &&
238+
lifecycleRestored &&
239+
action === 'restart_lead' &&
240+
current.teamIsAlive
241+
) {
242+
return {
243+
outcome: 'completed',
244+
effect: 'lead_restart_rolled_back',
245+
memberName: current.name,
246+
previousFingerprint: actualFingerprint,
247+
currentFingerprint: actualFingerprint,
248+
replayed: false,
249+
recovery: {
250+
persistenceRestored: true,
251+
lifecycleRestored: true,
252+
cause: errorMessage(error),
253+
},
254+
};
255+
}
183256
if (persistenceRestored && lifecycleRestored) {
184257
throw error;
185258
}

src/features/team-provisioning/core/domain/memberSettingsPolicy.ts

Lines changed: 31 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,7 @@ export interface MemberSettingsTargetSnapshot {
2626

2727
export type MemberSettingsLifecycleAction =
2828
| 'none'
29+
| 'restart_lead'
2930
| 'restart_member'
3031
| 'restart_opencode_lane'
3132
| 'require_team_relaunch';
@@ -105,9 +106,38 @@ export function selectMemberSettingsLifecycleAction(
105106
const hasReservedLeadRole = proposed.settings.role
106107
? isReservedLeadRole(proposed.settings.role)
107108
: false;
108-
if (isCanonicalLeadTarget(before) || isCanonicalLeadTarget(proposed) || hasReservedLeadRole) {
109+
const isLead = isCanonicalLeadTarget(before) || isCanonicalLeadTarget(proposed);
110+
if (!isLead && hasReservedLeadRole) {
109111
return 'require_team_relaunch';
110112
}
113+
if (isLead) {
114+
const beforeSettings = normalizeEditableMemberSettings(before.settings);
115+
const proposedSettings = normalizeEditableMemberSettings(proposed.settings);
116+
const lockedSettingsChanged = (
117+
[
118+
'role',
119+
'workflow',
120+
'isolation',
121+
'providerId',
122+
'providerBackendId',
123+
'fastMode',
124+
'mcpPolicy',
125+
] as const
126+
).some(
127+
(field) => JSON.stringify(beforeSettings[field]) !== JSON.stringify(proposedSettings[field])
128+
);
129+
if (lockedSettingsChanged) return 'require_team_relaunch';
130+
if (!before.teamIsAlive) return 'restart_lead';
131+
if (
132+
before.leadProviderId === 'opencode' ||
133+
(before.leadProviderId !== 'anthropic' &&
134+
before.leadProviderId !== 'codex' &&
135+
before.leadProviderId !== 'gemini')
136+
) {
137+
return 'require_team_relaunch';
138+
}
139+
return 'restart_lead';
140+
}
111141
if (!before.teamIsAlive) {
112142
return 'none';
113143
}

0 commit comments

Comments
 (0)