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:
parent
1f6c9fe34b
commit
7e6c0da21e
4 changed files with 222 additions and 19 deletions
|
|
@ -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.'
|
||||
|
|
|
|||
|
|
@ -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) &&
|
||||
|
|
|
|||
|
|
@ -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' }))
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
Loading…
Reference in a new issue