fix(team): avoid epoch bootstrap timestamps

This commit is contained in:
777genius 2026-06-06 20:47:16 +03:00
parent 08b1de7fa2
commit 0551c9c363
2 changed files with 74 additions and 15 deletions

View file

@ -85,18 +85,21 @@ function resolveLeadName(config: TeamConfig): string {
return lead?.name?.trim() || 'team-lead'; return lead?.name?.trim() || 'team-lead';
} }
function resolveSyntheticBootstrapTimestamp(config: TeamConfig, member: TeamConfigMember): string { function resolveSyntheticBootstrapTimestamp(
config: TeamConfig,
member: TeamConfigMember
): string | null {
const raw = member.joinedAt ?? (config as { createdAt?: unknown }).createdAt; const raw = member.joinedAt ?? (config as { createdAt?: unknown }).createdAt;
if (typeof raw === 'number' && Number.isFinite(raw)) { if (typeof raw === 'number' && Number.isFinite(raw) && raw > 0) {
return new Date(raw).toISOString(); return new Date(raw).toISOString();
} }
if (typeof raw === 'string') { if (typeof raw === 'string') {
const parsed = Date.parse(raw); const parsed = Date.parse(raw);
if (Number.isFinite(parsed)) { if (Number.isFinite(parsed) && parsed > 0) {
return new Date(parsed).toISOString(); return new Date(parsed).toISOString();
} }
} }
return new Date(0).toISOString(); return null;
} }
function buildSyntheticBootstrapDisplayPrompt( function buildSyntheticBootstrapDisplayPrompt(
@ -122,7 +125,10 @@ Call member_briefing directly yourself. Do NOT use Agent, any subagent, or a del
After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally.`; After member_briefing succeeds, wait for instructions from the lead and use team mailbox/task tools normally.`;
} }
function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] { function buildSyntheticBootstrapMessages(
config: TeamConfig,
fallbackTimestampForMessage: (messageId: string) => string
): InboxMessage[] {
const members = Array.isArray(config.members) ? config.members : []; const members = Array.isArray(config.members) ? config.members : [];
const leadName = resolveLeadName(config); const leadName = resolveLeadName(config);
const normalizedLeadName = leadName.trim().toLowerCase(); const normalizedLeadName = leadName.trim().toLowerCase();
@ -134,15 +140,20 @@ function buildSyntheticBootstrapMessages(config: TeamConfig): InboxMessage[] {
member.name.trim().toLowerCase() !== normalizedLeadName && member.name.trim().toLowerCase() !== normalizedLeadName &&
member.removedAt == null member.removedAt == null
) )
.map((member) => ({ .map((member) => {
from: leadName, const messageId = `bootstrap-start:${config.name}:${member.name}`;
to: member.name, return {
text: buildSyntheticBootstrapDisplayPrompt(config, member), from: leadName,
timestamp: resolveSyntheticBootstrapTimestamp(config, member), to: member.name,
read: true, text: buildSyntheticBootstrapDisplayPrompt(config, member),
source: 'system_notification' as const, timestamp:
messageId: `bootstrap-start:${config.name}:${member.name}`, resolveSyntheticBootstrapTimestamp(config, member) ??
})); fallbackTimestampForMessage(messageId),
read: true,
source: 'system_notification' as const,
messageId,
};
});
} }
function isVisibleTeamMessage(message: InboxMessage): boolean { function isVisibleTeamMessage(message: InboxMessage): boolean {
@ -429,6 +440,7 @@ export class TeamMessageFeedService {
private readonly dirtyTeams = new Set<string>(); private readonly dirtyTeams = new Set<string>();
private readonly inFlightByTeam = new Map<string, InFlightTeamMessageFeed>(); private readonly inFlightByTeam = new Map<string, InFlightTeamMessageFeed>();
private readonly generationByTeam = new Map<string, number>(); private readonly generationByTeam = new Map<string, number>();
private readonly syntheticBootstrapTimestampByMessageId = new Map<string, string>();
constructor(private readonly deps: TeamMessageFeedDeps) {} constructor(private readonly deps: TeamMessageFeedDeps) {}
@ -487,6 +499,17 @@ export class TeamMessageFeedService {
return this.generationByTeam.get(teamName) ?? 0; return this.generationByTeam.get(teamName) ?? 0;
} }
private getSyntheticBootstrapFallbackTimestamp(messageId: string): string {
const existing = this.syntheticBootstrapTimestampByMessageId.get(messageId);
if (existing) {
return existing;
}
const timestamp = new Date(Date.now()).toISOString();
this.syntheticBootstrapTimestampByMessageId.set(messageId, timestamp);
return timestamp;
}
private refreshCleanExpiredCacheInBackground( private refreshCleanExpiredCacheInBackground(
teamName: string, teamName: string,
cached: TeamMessageFeedCacheEntry, cached: TeamMessageFeedCacheEntry,
@ -554,7 +577,9 @@ export class TeamMessageFeedService {
const sourceMs = Date.now() - sourceStartedAt; const sourceMs = Date.now() - sourceStartedAt;
const normalizeStartedAt = Date.now(); const normalizeStartedAt = Date.now();
const syntheticMessages = buildSyntheticBootstrapMessages(config); const syntheticMessages = buildSyntheticBootstrapMessages(config, (messageId) =>
this.getSyntheticBootstrapFallbackTimestamp(messageId)
);
let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter( let messages = [...inboxMessages, ...leadTexts, ...sentMessages, ...syntheticMessages].filter(
isVisibleTeamMessage isVisibleTeamMessage
); );

View file

@ -120,6 +120,40 @@ describe('TeamMessageFeedService', () => {
expect(feed.messages[0].text).toContain('member_briefing'); expect(feed.messages[0].text).toContain('member_briefing');
}); });
it('does not stamp synthetic bootstrap prompts with Unix epoch when config has no join time', async () => {
const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => ({
name: 'opencode-test',
members: [
{ name: 'team-lead', role: 'Lead' },
{
name: 'alice',
role: 'Developer',
providerId: 'opencode' as const,
model: 'openrouter/big-pickle',
},
],
})),
getInboxMessages: vi.fn(async () => []),
getLeadSessionMessages: vi.fn(async () => []),
getSentMessages: vi.fn(async () => []),
});
const first = await service.getFeed('opencode-test');
expect(first.messages).toHaveLength(1);
expect(first.messages[0].messageId).toBe('bootstrap-start:opencode-test:alice');
expect(first.messages[0].timestamp).toBe('2026-04-19T18:46:40.000Z');
expect(first.messages[0].timestamp).not.toBe('1970-01-01T00:00:00.000Z');
vi.setSystemTime(new Date('2026-04-19T18:47:00.000Z'));
service.invalidate('opencode-test');
const refreshed = await service.getFeed('opencode-test');
expect(refreshed.messages[0].timestamp).toBe(first.messages[0].timestamp);
expect(refreshed.feedRevision).toBe(first.feedRevision);
});
it('does not hide user-authored text just because it resembles an internal prompt', async () => { it('does not hide user-authored text just because it resembles an internal prompt', async () => {
const service = new TeamMessageFeedService({ const service = new TeamMessageFeedService({
getConfig: vi.fn(async () => config), getConfig: vi.fn(async () => config),