fix(assistant): a change left waiting past its day no longer holds the next one back

The open changes of a thread are now the ones still inside their decision
window. A call older than a day already reads as expired in the thread and
can no longer be decided, but it stayed approval_requested until the nightly
sweep, so "+ Tour", "+ Day" and "Plan this" answered decide_first with
nothing left to decide, and a decision had to name the expired call too.
listOpen takes the window's start, and every caller passes the same cutoff.
This commit is contained in:
Maurice
2026-10-24 01:21:18 +00:00
parent a6a9f3bbdd
commit bfcc205f05
10 changed files with 100 additions and 21 deletions
@@ -145,13 +145,18 @@ export class AssistantToolCallsRepository extends TrekRepository<AssistantToolCa
.execute();
}
/** The calls of a thread still waiting for a decision. */
async listOpen(threadId: number): Promise<AssistantToolCallRow[]> {
/**
* The calls of a thread still waiting for a decision, proposed at or after
* `notBefore`: an older one has expired, as the thread shows it and as
* `claimDecision` refuses it, even before the nightly sweep marks it so.
*/
async listOpen(threadId: number, notBefore: string): Promise<AssistantToolCallRow[]> {
return this.db()
.selectFrom('assistant_tool_calls')
.select(COLUMNS)
.where('thread_id', '=', threadId)
.where('state', '=', 'approval_requested')
.where('created_at', '>=', notBefore)
.orderBy('created_at')
.orderBy('id')
.execute();
@@ -30,7 +30,7 @@ import {
} from './assistant-context';
import { partsOf, toThreadMessages } from './assistant-history';
import { AssistantStreamRegistry } from './assistant-stream.registry';
import { ADD_FOUND_PLACE_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
import { ADD_FOUND_PLACE_TOOL, decisionCutoff, TURNS_PER_MINUTE } from './assistant-tools.constants';
import { AssistantToolsService } from './assistant-tools.service';
import { AssistantCardContext } from './cards/card-context';
import { recordOf } from './cards/card-values';
@@ -248,7 +248,7 @@ export class AssistantFoundPlacesService {
tourId: number | null,
argsOf: ArgsOf,
): Promise<void> {
if ((await this.toolCalls.listOpen(threadId)).length > 0) {
if ((await this.toolCalls.listOpen(threadId, decisionCutoff())).length > 0) {
throw aiRefusal('decide_first', 'Decide on the changes that are waiting first');
}
const place = foundPlace(await this.messages.listLatest(threadId, SEARCHED_ROWS), req.ref);
@@ -25,7 +25,7 @@ import { partsOf, toThreadMessages } from './assistant-history';
import { isSportPlan, SPORT_CREATES, sportTakeArgsOf, tourTypeWord } from './assistant-proposal-sports';
import { isWayProposal, WAY_CREATES, wayTakeArgsOf } from './assistant-proposal-ways';
import { AssistantStreamRegistry } from './assistant-stream.registry';
import { TAKE_PROPOSAL_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
import { decisionCutoff, TAKE_PROPOSAL_TOOL, TURNS_PER_MINUTE } from './assistant-tools.constants';
import { AssistantToolsService, type ToolRun } from './assistant-tools.service';
import { AssistantCardContext } from './cards/card-context';
import { recordOf, type Row } from './cards/card-values';
@@ -276,7 +276,7 @@ export class AssistantProposalsService {
}
private async hold(userId: number, tripId: number, threadId: number, req: AssistantTakeProposalRequest) {
if ((await this.toolCalls.listOpen(threadId)).length > 0) {
if ((await this.toolCalls.listOpen(threadId, decisionCutoff())).length > 0) {
throw aiRefusal('decide_first', 'Decide on the changes that are waiting first');
}
const stored = storedProposal(await this.messages.listLatest(threadId, SEARCHED_ROWS), req.toolCallId);
@@ -7,7 +7,7 @@ import { AiConnectionsService } from '../ai-connections/ai-connections.service';
import { AiUsageService } from '../ai-connections/ai-usage.service';
import { logError, logInfo } from '../audit/audit-log.logger';
import { CronRegistrarService } from '../scheduling/cron-registrar.service';
import { APPROVAL_TTL_MS } from './assistant-tools.constants';
import { decisionCutoff } from './assistant-tools.constants';
import { InjectRepository } from '@mikro-orm/nestjs';
import { Injectable, type OnApplicationBootstrap } from '@nestjs/common';
@@ -51,7 +51,7 @@ export class AssistantRetentionJob implements OnApplicationBootstrap {
: 0;
const orphaned = await this.threads.deleteWithoutAccess();
if (idle + orphaned > 0) logInfo(`Assistant retention: removed ${idle + orphaned} conversation(s)`);
const expired = await this.toolCalls.expireBefore(dbNow(new Date(now.getTime() - APPROVAL_TTL_MS)));
const expired = await this.toolCalls.expireBefore(decisionCutoff(now));
if (expired.length > 0) logInfo(`Assistant retention: ${expired.length} proposed change(s) expired`);
await this.usage.purgeOlderThan(ASSISTANT_USAGE_RETENTION_DAYS);
} catch (err: unknown) {
@@ -1,3 +1,4 @@
import { dbNow, type DbTimestamp } from '../../db/types';
import type { ToolUndo } from './cards/card-types';
import { argId, idIn } from './cards/card-values';
import type { AssistantApplied, AssistantChangeVerbKey } from '@trek/shared';
@@ -27,6 +28,11 @@ export const UNDO_WINDOW_MS = 15 * 60_000;
/** How long a proposed change waits for a decision before it expires. */
export const APPROVAL_TTL_MS = 24 * 60 * 60_000;
/** The oldest a change still waiting at `now` may have been proposed and still be decided, as the database writes it. */
export function decisionCutoff(now: Date = new Date()): DbTimestamp {
return dbNow(new Date(now.getTime() - APPROVAL_TTL_MS));
}
/** Turns (chat, decisions, undo) a user may start per minute. */
export const TURNS_PER_MINUTE = 30;
@@ -45,8 +45,8 @@ import { AssistantScopes, findScopedCall, scopedThreads, tripScope } from './ass
import { AssistantStreamRegistry } from './assistant-stream.registry';
import { titleFrom } from './assistant-title';
import {
APPROVAL_TTL_MS,
ASSISTANT_SERVER_WRITE_TOOLS,
decisionCutoff,
foundPlaceArgs,
IMPORT_BOOKING_TOOL,
REMEMBER_TOOL,
@@ -316,7 +316,7 @@ export class AssistantService {
): Promise<void> {
if (!(await scopedThreads(this.threads, scope).find(threadId, userId))) throw threadNotFound();
await this.admit(userId);
const open = await this.toolCalls.listOpen(threadId);
const open = await this.toolCalls.listOpen(threadId, decisionCutoff());
if (open.length > 0 && open.every((call) => ASSISTANT_SERVER_WRITE_TOOLS[call.tool_name]?.quiet === true)) {
await this.decideQuietly(userId, scope, threadId, req, channel);
return;
@@ -624,7 +624,7 @@ export class AssistantService {
}
private async cancelOpen(threadId: number, now: string): Promise<void> {
const open = await this.toolCalls.listOpen(threadId);
const open = await this.toolCalls.listOpen(threadId, decisionCutoff());
const claimed = await Promise.all(
open.map((call) => this.toolCalls.claimDecision(call.id, threadId, false, dbNow(new Date(0)), now)),
);
@@ -644,14 +644,16 @@ export class AssistantService {
/**
* Records the decisions, each only while its change still waits and is less
* than a day old. The request must name every waiting change and nothing
* else (422); when none could be taken any more, 409.
* than a day old. The request must name every change waiting within its day
* and nothing else (422); when none could be taken any more, 409.
*/
private async claimDecisions(
threadId: number,
decisions: AssistantDecision[],
): Promise<Array<{ call: AssistantToolCallRow; approved: boolean }>> {
const open = await this.toolCalls.listOpen(threadId);
const now = new Date();
const notBefore = decisionCutoff(now);
const open = await this.toolCalls.listOpen(threadId, notBefore);
if (open.length === 0) throw conflict('No change is waiting for a decision', { code: 'generic' });
const wanted = new Map(decisions.map((decision) => [decision.toolCallId, decision.approved]));
const complete =
@@ -659,8 +661,6 @@ export class AssistantService {
if (!complete) {
throw new DomainError(422, 'Decide on every change that is waiting', { details: { code: 'generic' } });
}
const now = new Date();
const notBefore = dbNow(new Date(now.getTime() - APPROVAL_TTL_MS));
// In the order the decisions name them, the order the chat shows the changes in: the changes of one step
// share their time, and their ids sort at random, so only this order lets "undo all" walk them back.
const position = new Map(decisions.map((decision, i) => [decision.toolCallId, i]));