fix(team): allow same-run bootstrap clock skew for native proofs

resolveBootstrapRuntimeEvidenceBoundaryMs учитывает оба источника
времени старта (firstSpawnAcceptedAt и bootstrapExpectedAfter) и
принимает более раннее, если у member и runtime совпадает
bootstrapProofToken + runId. Это лечит случай, когда proof подписан
до того, как app зафиксировал firstSpawnAcceptedAt, но после
bootstrap boundary самого ранкона. Та же логика применена в
isBootstrapMemberEvidenceCurrentForMember для confirmation evidence.
This commit is contained in:
777genius 2026-05-29 00:03:17 +03:00
parent 1f6c9fe34b
commit 7e6c0da21e
4 changed files with 222 additions and 19 deletions

View file

@ -5017,7 +5017,11 @@ export class TeamProvisioningService {
}
private readPersistedTeamProcessRows(teamName: string): unknown[] | null {
const processesPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), teamName, 'processes.json');
const processesPath = this.resolveSafeTeamStoragePath(
getTeamsBasePath(),
teamName,
'processes.json'
);
let parsed: unknown;
try {
parsed = JSON.parse(fs.readFileSync(processesPath, 'utf8')) as unknown;
@ -14768,7 +14772,11 @@ export class TeamProvisioningService {
bootstrapContextHash?: string;
bootstrapBriefingHash?: string;
}): Promise<void> {
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), input.teamName, 'config.json');
const configPath = this.resolveSafeTeamStoragePath(
getTeamsBasePath(),
input.teamName,
'config.json'
);
const raw = await tryReadRegularFileUtf8(configPath, {
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
maxBytes: TEAM_CONFIG_MAX_BYTES,
@ -19982,7 +19990,11 @@ export class TeamProvisioningService {
try {
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
for (const probe of teamsBasePathsToProbe) {
const configPath = this.resolveSafeTeamStoragePath(probe.basePath, request.teamName, 'config.json');
const configPath = this.resolveSafeTeamStoragePath(
probe.basePath,
request.teamName,
'config.json'
);
if (await this.pathExists(configPath)) {
const suffix = probe.location === 'configured' ? '' : ` (found under ${probe.basePath})`;
throw new Error(`Team already exists${suffix}`);
@ -20602,7 +20614,11 @@ export class TeamProvisioningService {
): Promise<TeamCreateResponse> {
const teamsBasePathsToProbe = getTeamsBasePathsToProbe();
for (const probe of teamsBasePathsToProbe) {
const configPath = this.resolveSafeTeamStoragePath(probe.basePath, request.teamName, 'config.json');
const configPath = this.resolveSafeTeamStoragePath(
probe.basePath,
request.teamName,
'config.json'
);
if (await this.pathExists(configPath)) {
const suffix = probe.location === 'configured' ? '' : ` (found under ${probe.basePath})`;
throw new Error(`Team already exists${suffix}`);
@ -20661,7 +20677,11 @@ export class TeamProvisioningService {
request: TeamLaunchRequest,
onProgress: (progress: TeamProvisioningProgress) => void
): Promise<TeamLaunchResponse> {
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName, 'config.json');
const configPath = this.resolveSafeTeamStoragePath(
getTeamsBasePath(),
request.teamName,
'config.json'
);
const configRaw = await tryReadRegularFileUtf8(configPath, {
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
maxBytes: TEAM_CONFIG_MAX_BYTES,
@ -20943,7 +20963,11 @@ export class TeamProvisioningService {
request: TeamCreateRequest,
members: TeamCreateRequest['members']
): Promise<void> {
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName, 'config.json');
const configPath = this.resolveSafeTeamStoragePath(
getTeamsBasePath(),
request.teamName,
'config.json'
);
const config: TeamConfig = {
name: request.displayName?.trim() || request.teamName,
description: request.description,
@ -21182,7 +21206,11 @@ export class TeamProvisioningService {
try {
// Verify config.json exists — team must already be provisioned
const configPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), request.teamName, 'config.json');
const configPath = this.resolveSafeTeamStoragePath(
getTeamsBasePath(),
request.teamName,
'config.json'
);
const configRaw = await tryReadRegularFileUtf8(configPath, {
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
maxBytes: TEAM_CONFIG_MAX_BYTES,
@ -29399,17 +29427,41 @@ export class TeamProvisioningService {
);
}
private resolveBootstrapRuntimeEvidenceBoundaryMs(
member: Pick<PersistedTeamLaunchMemberState, 'firstSpawnAcceptedAt' | 'runtimeRunId'>,
runtimeMember: PersistedRuntimeMemberLike | undefined
): number {
const firstSpawnAcceptedMs = Date.parse(member.firstSpawnAcceptedAt ?? '');
const bootstrapExpectedAfterMs = Date.parse(runtimeMember?.bootstrapExpectedAfter ?? '');
if (!Number.isFinite(firstSpawnAcceptedMs)) {
return Number.isFinite(bootstrapExpectedAfterMs) ? bootstrapExpectedAfterMs : Number.NaN;
}
if (!Number.isFinite(bootstrapExpectedAfterMs)) {
return firstSpawnAcceptedMs;
}
const proofToken = runtimeMember?.bootstrapProofToken?.trim() ?? '';
const memberRunId = typeof member.runtimeRunId === 'string' ? member.runtimeRunId.trim() : '';
const runtimeRunId = runtimeMember?.bootstrapRunId?.trim() ?? '';
const runIdsCompatible =
memberRunId.length === 0 || runtimeRunId.length === 0 || memberRunId === runtimeRunId;
if (proofToken.length === 0 || !runIdsCompatible) {
return firstSpawnAcceptedMs;
}
return Math.min(firstSpawnAcceptedMs, bootstrapExpectedAfterMs);
}
private async findBootstrapRuntimeProofObservedAt(
teamName: string,
memberName: string,
member: Pick<
PersistedTeamLaunchMemberState,
'firstSpawnAcceptedAt' | 'launchState' | 'hardFailureReason'
'firstSpawnAcceptedAt' | 'launchState' | 'hardFailureReason' | 'runtimeRunId'
>
): Promise<string | null> {
const runtimeMember = this.resolveBootstrapRuntimeMember(teamName, memberName);
const boundaryText = member.firstSpawnAcceptedAt ?? runtimeMember?.bootstrapExpectedAfter;
const boundaryMs = boundaryText ? Date.parse(boundaryText) : Number.NaN;
const boundaryMs = this.resolveBootstrapRuntimeEvidenceBoundaryMs(member, runtimeMember);
if (!runtimeMember?.bootstrapProofToken && !Number.isFinite(boundaryMs)) {
return null;
}
@ -29510,8 +29562,7 @@ export class TeamProvisioningService {
if (runtimeBackendType !== 'process' && !processPaneId?.startsWith('process:')) {
return null;
}
const boundaryText = member.firstSpawnAcceptedAt ?? runtimeMember?.bootstrapExpectedAfter;
const boundaryMs = boundaryText ? Date.parse(boundaryText) : Number.NaN;
const boundaryMs = this.resolveBootstrapRuntimeEvidenceBoundaryMs(member, runtimeMember);
const expectedPid =
typeof member.runtimePid === 'number' && member.runtimePid > 0
? member.runtimePid
@ -34451,7 +34502,11 @@ export class TeamProvisioningService {
// Best-effort: detect CLI-suffixed member names (alice-2, bob-2) that indicate
// a stale config.json was present during launch (double-launch race).
try {
const postLaunchConfigPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), run.teamName, 'config.json');
const postLaunchConfigPath = this.resolveSafeTeamStoragePath(
getTeamsBasePath(),
run.teamName,
'config.json'
);
const raw = await tryReadRegularFileUtf8(postLaunchConfigPath, {
timeoutMs: TEAM_JSON_READ_TIMEOUT_MS,
maxBytes: TEAM_CONFIG_MAX_BYTES,
@ -35787,9 +35842,17 @@ export class TeamProvisioningService {
}
if (code === 0) {
const configuredConfigPath = this.resolveSafeTeamStoragePath(getTeamsBasePath(), run.teamName, 'config.json');
const configuredConfigPath = this.resolveSafeTeamStoragePath(
getTeamsBasePath(),
run.teamName,
'config.json'
);
const defaultTeamsBasePath = path.join(getAutoDetectedClaudeBasePath(), 'teams');
const defaultConfigPath = this.resolveSafeTeamStoragePath(defaultTeamsBasePath, run.teamName, 'config.json');
const defaultConfigPath = this.resolveSafeTeamStoragePath(
defaultTeamsBasePath,
run.teamName,
'config.json'
);
const combinedLogs = buildCombinedLogs(run.stdoutBuffer, run.stderrBuffer);
const cleanupHint = logsSuggestShutdownOrCleanup(combinedLogs)
? ' CLI output suggests the team was shut down / cleaned up, so no persisted config was left on disk.'

View file

@ -600,6 +600,10 @@ export function isBootstrapMemberEvidenceCurrentForMember(
typeof current.runtimeRunId === 'string' ? current.runtimeRunId.trim() : '';
const bootstrapRuntimeRunId =
typeof bootstrapMember.runtimeRunId === 'string' ? bootstrapMember.runtimeRunId.trim() : '';
const hasSameRuntimeRunId =
currentRuntimeRunId.length > 0 &&
bootstrapRuntimeRunId.length > 0 &&
currentRuntimeRunId === bootstrapRuntimeRunId;
if (
currentRuntimeRunId.length > 0 &&
bootstrapRuntimeRunId.length > 0 &&
@ -631,10 +635,18 @@ export function isBootstrapMemberEvidenceCurrentForMember(
const hasDurableSpawnBoundary =
Number.isFinite(firstSpawnAcceptedMs) &&
(!Number.isFinite(lastEvaluatedMs) || firstSpawnAcceptedMs <= lastEvaluatedMs);
const boundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN;
const hasCompatibleRuntimeRunIdForSkew =
currentRuntimeRunId.length === 0 ||
(bootstrapRuntimeRunId.length > 0 && currentRuntimeRunId === bootstrapRuntimeRunId);
const currentBoundaryMs = hasDurableSpawnBoundary ? firstSpawnAcceptedMs : NaN;
const sameRunBootstrapBoundaryMs =
evidenceKind === 'confirmation' && hasSameRuntimeRunId && hasDurableBootstrapSpawnAcceptedAt
? bootstrapFirstSpawnAcceptedMs
: NaN;
const boundaryMs =
Number.isFinite(currentBoundaryMs) && Number.isFinite(sameRunBootstrapBoundaryMs)
? Math.min(currentBoundaryMs, sameRunBootstrapBoundaryMs)
: Number.isFinite(currentBoundaryMs)
? currentBoundaryMs
: sameRunBootstrapBoundaryMs;
const hasCompatibleRuntimeRunIdForSkew = currentRuntimeRunId.length === 0 || hasSameRuntimeRunId;
const withinBootstrapConfirmationClockSkew =
evidenceKind === 'confirmation' &&
Number.isFinite(boundaryMs) &&

View file

@ -395,6 +395,35 @@ describe('TeamProvisioningOpenCodeRuntimeEvidencePolicy', () => {
).toBe(false);
});
it('accepts same-run bootstrap confirmation before delayed app acceptance past skew', () => {
const current = {
firstSpawnAcceptedAt: '2026-05-24T09:25:52.497Z',
lastEvaluatedAt: '2026-05-24T09:31:05.525Z',
runtimeRunId: 'run-process-table-unavailable-skew',
};
const bootstrapMember = {
firstSpawnAcceptedAt: '2026-05-24T09:25:33.388Z',
lastHeartbeatAt: '2026-05-24T09:25:42.494Z',
lastRuntimeAliveAt: '2026-05-24T09:25:42.494Z',
lastEvaluatedAt: '2026-05-24T09:25:42.494Z',
runtimeRunId: 'run-process-table-unavailable-skew',
};
expect(
isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'confirmation')
).toBe(true);
expect(
isBootstrapMemberEvidenceCurrentForMember(
current,
{ ...bootstrapMember, runtimeRunId: 'previous-run' },
'confirmation'
)
).toBe(false);
expect(isBootstrapMemberEvidenceCurrentForMember(current, bootstrapMember, 'acceptance')).toBe(
false
);
});
it('classifies recoverable persisted OpenCode runtime candidates', () => {
expect(
isRecoverablePersistedOpenCodeRuntimeCandidate(makePersisted({ runtimeSessionId: 'rt-1' }))

View file

@ -20579,6 +20579,105 @@ describe('TeamProvisioningService', () => {
});
});
it('heals native runtime proof that predates delayed app acceptance but follows bootstrap boundary', async () => {
allowConsoleLogs();
const teamName = 'zz-unit-native-proof-delayed-app-acceptance-heals';
const leadSessionId = 'lead-session';
const projectPath = '/Users/test/proj';
const bootstrapExpectedAfter = '2026-05-28T14:32:47.928Z';
const proofAt = '2026-05-28T14:32:54.123Z';
const appAcceptedAt = '2026-05-28T14:33:04.126Z';
const cleanupAt = '2026-05-28T14:39:22.768Z';
const proofToken = 'proof-token-tom-native';
const bootstrapRunId = 'run-native-delayed-acceptance';
const contextHash = 'c'.repeat(64);
const briefingHash = 'd'.repeat(64);
const runtimePid = 48_518;
const runtimeEventsPath = path.join(tempTeamsBase, teamName, 'runtime', 'tom.runtime.jsonl');
const processTableReason =
'runtime pid could not be verified because process table is unavailable';
writeLaunchConfig(teamName, projectPath, leadSessionId, ['tom']);
const configPath = path.join(tempTeamsBase, teamName, 'config.json');
const config = JSON.parse(fs.readFileSync(configPath, 'utf8')) as {
members: Array<Record<string, unknown>>;
};
config.members = config.members.map((member) =>
member.name === 'tom'
? {
...member,
agentId: `tom@${teamName}`,
backendType: 'process',
tmuxPaneId: `process:${runtimePid}`,
runtimePid,
bootstrapExpectedAfter,
bootstrapProofToken: proofToken,
bootstrapRunId,
bootstrapProofMode: 'native_app_managed_context',
bootstrapContextHash: contextHash,
bootstrapBriefingHash: briefingHash,
bootstrapRuntimeEventsPath: runtimeEventsPath,
}
: member
);
fs.writeFileSync(configPath, JSON.stringify(config), 'utf8');
const snapshot = createPersistedLaunchSnapshot({
teamName,
leadSessionId,
launchPhase: 'finished',
expectedMembers: ['tom'],
members: {
tom: {
name: 'tom',
launchState: 'failed_to_start',
agentToolAccepted: true,
runtimeAlive: false,
runtimePid,
bootstrapConfirmed: false,
hardFailure: true,
hardFailureReason: processTableReason,
livenessKind: 'registered_only',
runtimeDiagnostic: processTableReason,
runtimeDiagnosticSeverity: 'warning',
firstSpawnAcceptedAt: appAcceptedAt,
runtimeLastSeenAt: cleanupAt,
lastEvaluatedAt: cleanupAt,
},
},
});
fs.mkdirSync(path.dirname(runtimeEventsPath), { recursive: true });
fs.writeFileSync(
runtimeEventsPath,
`${JSON.stringify({
version: 1,
type: 'bootstrap_confirmed',
timestamp: proofAt,
pid: runtimePid,
teamName,
agentName: 'tom',
agentId: `tom@${teamName}`,
runId: bootstrapRunId,
bootstrapRunId,
source: 'native_app_managed_bootstrap_private_turn',
bootstrapProofToken: proofToken,
contextHash,
briefingHash,
})}\n`,
'utf8'
);
const svc = new TeamProvisioningService();
const result = await privateHarness(svc).applyBootstrapTranscriptEvidenceOverlay(snapshot);
expect(result?.members.tom).toMatchObject({
launchState: 'confirmed_alive',
bootstrapConfirmed: true,
runtimeAlive: true,
hardFailure: false,
hardFailureReason: undefined,
});
});
it('heals cleanup-finalized launch failures when bootstrap-state confirms an Anthropic primary member', async () => {
allowConsoleLogs();
const teamName = 'zz-unit-cleanup-finalized-bootstrap-state-heals';