Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
12 changes: 7 additions & 5 deletions backend/src/modules/assistant-workspace/profiles.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,16 +69,18 @@ export async function readAssistantProfiles(
work_start: string | null;
work_end: string | null;
timezone: string | null;
observed_at: Date;
observed_at: string;
}>(
`SELECT a.night_shift,p.work_start::text,p.work_end::text,p.timezone,current_timestamp AS observed_at
`SELECT a.night_shift,p.work_start::text,p.work_end::text,p.timezone,current_timestamp::text AS observed_at
FROM users u LEFT JOIN agent_settings a ON a.user_id=u.id LEFT JOIN planner_prefs p ON p.user_id=u.id
WHERE u.id=$1 AND NOT u.disabled`,
[owner],
)
).rows[0];
if (!row) fail(404, "That assistant profile is not here.");
const observed = now ?? row.observed_at;
// Preserve database microseconds for SQL comparisons; Date is only for UI/calendar work.
const observedAt = now?.toISOString() ?? row.observed_at;
const observed = now ?? new Date(row.observed_at);
const parsed = nightShiftInput.safeParse(row.night_shift);
const settings = parsed.success
? parsed.data
Expand Down Expand Up @@ -109,7 +111,7 @@ export async function readAssistantProfiles(
await db.query<{ last_seen_at: Date | null; reporting: boolean }>(
`SELECT last_seen_at,last_seen_at BETWEEN $2::timestamptz - interval '30 seconds' AND $2::timestamptz AS reporting
FROM service_heartbeats WHERE service=$1`,
[`assistant-${lane}`, observed],
[`assistant-${lane}`, observedAt],
)
).rows[0];
const counts = (
Expand All @@ -120,7 +122,7 @@ export async function readAssistantProfiles(
FROM ai_jobs j LEFT JOIN ai_chats c ON c.id=j.chat_id
WHERE j.user_id=$1 AND j.runtime_lane=$3 AND j.state IN ('queued','running','waiting')
AND ${assistantChatVisible("c", "$1")} AND ${assistantJobSourcesVisible("j", "$1", false)}`,
[owner, observed, lane],
[owner, observedAt, lane],
)
).rows[0];
const lastActivity = await assistantLastActivity(db, owner, lane);
Expand Down
41 changes: 41 additions & 0 deletions backend/tests/assistant-profiles.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,47 @@ test("profile runtime availability follows worker heartbeats without inventing a
"DELETE FROM service_heartbeats WHERE service IN ('assistant-background','assistant-overnight')",
);
});
test("heartbeat comparisons retain PostgreSQL precision at the observation instant", async () => {
const user = await person();
const db = await pool.connect();
try {
// Pin the observation and heartbeat to one transaction instant. Ensure its
// sub-millisecond part is nonzero so a Date round-trip cannot hide the bug.
let precise = false;
for (let attempt = 0; attempt < 10; attempt++) {
await db.query("BEGIN");
precise = (
await db.query<{ precise: boolean }>(
"SELECT current_timestamp > date_trunc('milliseconds',current_timestamp) AS precise",
)
).rows[0].precise;
if (precise) break;
await db.query("ROLLBACK");
}
assert.ok(
precise,
"fixture must exercise a sub-millisecond database instant",
);
await db.query(
"DELETE FROM service_heartbeats WHERE service IN ('assistant-background','assistant-overnight')",
);
await db.query(
"INSERT INTO service_heartbeats(service,last_seen_at) VALUES('assistant-background',current_timestamp),('assistant-overnight',current_timestamp + interval '1 second')",
);
const page = await readAssistantProfiles(db, user.id);
assert.deepEqual(
page.profiles.map((profile) => profile.runtime.reporting),
[true, false],
"a heartbeat at the observation instant is fresh; a future heartbeat is not",
);
assert.ok(
page.profiles.every((profile) => profile.last_activity_at === null),
);
} finally {
await db.query("ROLLBACK");
db.release();
}
});
test("working requires an unexpired lease; recovery is queued and lanes stay separate", async () => {
const user = await person();
const background = await job(user.id, "background");
Expand Down
Loading