Q52: goal closures stop linked work, and running sessions hear goal changes
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
d9cb86a999
commit
69e991ff93
5 files changed
+134
-1
No files matched your search
@@ -0,0 +1,33 @@
|
||||
import type { DatabaseSync } from "node:sqlite";
|
||||
|
||||
interface GoalState {
|
||||
title: string;
|
||||
description: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
/** Returns a notice whenever the run's goal is rewritten or closed after the run started, once per change. */
|
||||
export function createGoalChangePoller(db: DatabaseSync, goalId: string | null): () => string | null {
|
||||
if (!goalId) return () => null;
|
||||
const read = () => db.prepare("select title, description, status from goals where id = ?").get(goalId) as GoalState | undefined;
|
||||
let seen = read();
|
||||
return () => {
|
||||
const now = read();
|
||||
if (!seen || !now || (now.title === seen.title && now.description === seen.description && now.status === seen.status)) return null;
|
||||
const before = seen;
|
||||
seen = now;
|
||||
console.info(`[task-run] goal ${goalId} changed mid-run (${now.status}); notifying the session`);
|
||||
if (now.status !== before.status && now.status !== "active") {
|
||||
return `The goal for this work ("${now.title}") is now ${now.status}. Stop starting new work on it: wrap up, report what you finished, and set this task's status.`;
|
||||
}
|
||||
return `The goal for this work changed while you were working. It now reads: "${now.title}" — ${now.description} Re-check your plan against it.`;
|
||||
};
|
||||
}
|
||||
|
||||
/** Joins mid-run notices from several sources into one follow-up, or null when none has news. */
|
||||
export function combineMidRunPolls(...polls: Array<() => string | null>): () => string | null {
|
||||
return () => {
|
||||
const notices = polls.map((poll) => poll()).filter((notice): notice is string => notice !== null);
|
||||
return notices.length > 0 ? notices.join("\n\n") : null;
|
||||
};
|
||||
}
|
||||
@@ -53,6 +53,7 @@ export class HeartbeatScheduler {
|
||||
and coalesce(u.request_count, 0) < p.monthly_request_limit
|
||||
and not exists (select 1 from task_runs r where r.task_id = t.id and r.state in ('queued', 'running'))
|
||||
and not exists (select 1 from tasks c where c.parent_task_id = t.id and c.status <> 'done')
|
||||
and (t.origin is not null or t.goal_id is null or exists (select 1 from goals g where g.id = t.goal_id and g.status = 'active'))
|
||||
and (a.heartbeat_interval_sec <= 0 or not exists (
|
||||
select 1 from task_runs hb where hb.agent_id = a.id and hb.trigger = 'heartbeat' and hb.state <> 'superseded'
|
||||
and hb.created_at > strftime('%Y-%m-%dT%H:%M:%fZ', ?, '-' || a.heartbeat_interval_sec || ' seconds')
|
||||
|
||||
@@ -11,6 +11,7 @@ import { deliverChatReplyIfMissing } from "./chat-agent-wake-service";
|
||||
import { SkillService } from "./skill-service";
|
||||
import { runAgentLoop, runTools } from "./agent-run-loop";
|
||||
import type { RunModel } from "./skill-runtime";
|
||||
import { combineMidRunPolls, createGoalChangePoller } from "./goal-change-poller";
|
||||
import type { AgentToolContext } from "./agent-tools";
|
||||
import {
|
||||
buildAgentInstructions,
|
||||
@@ -121,6 +122,10 @@ export class TaskExecutionService {
|
||||
if (!sameWorkspaceAccess(run.workspace_mount_json, workspaceAccess(task, workspace))) {
|
||||
return this.supersedeRun(orgId, runId, run, "the task's workspace access changed after this run was queued.");
|
||||
}
|
||||
const goal = task.origin === null && task.goal_id
|
||||
? this.db.prepare("select status from goals where id = ?").get(task.goal_id) as { status: string } | undefined
|
||||
: undefined;
|
||||
if (goal && goal.status !== "active") return this.supersedeRun(orgId, runId, run, `the task's goal is ${goal.status}, so its work stops.`);
|
||||
const openSubtask = this.db.prepare("select title from tasks where parent_task_id = ? and status <> 'done' limit 1").get(task.id) as { title: string } | undefined;
|
||||
if (openSubtask) return this.supersedeRun(orgId, runId, run, `the task has open subtasks ("${openSubtask.title}"); it runs again once they are done.`);
|
||||
const approvalProblem = executionApprovalProblem(this.db, orgId, task);
|
||||
@@ -187,6 +192,8 @@ export class TaskExecutionService {
|
||||
.toolkitsForAgent(orgId, agent.id)
|
||||
.map(connectorDisplayName);
|
||||
const language = this.appLanguage();
|
||||
// Baseline taken before any await, so a goal closed while tools load still reaches the session.
|
||||
const goalChanges = createGoalChangePoller(this.db, task.goal_id);
|
||||
const externalTools = await loadComposioTools(this.db, this.providers.integrationKeys, orgId, agent.id);
|
||||
const result = await runAgentLoop(
|
||||
{
|
||||
@@ -223,7 +230,10 @@ export class TaskExecutionService {
|
||||
externalTools,
|
||||
onModelTurn: contextRecorder.commit,
|
||||
inputBudgetTokens: inputBudgetTokens(provider.context_window_tokens),
|
||||
pollNewMessages: createRunMessagePoller(this.db, { orgId, onInjected: contextRecorder.addMidRun, agentId: agent.id, sourceThreadId: chatContext?.threadId ?? null, runStartedAt }),
|
||||
pollNewMessages: combineMidRunPolls(
|
||||
goalChanges,
|
||||
createRunMessagePoller(this.db, { orgId, onInjected: contextRecorder.addMidRun, agentId: agent.id, sourceThreadId: chatContext?.threadId ?? null, runStartedAt }),
|
||||
),
|
||||
},
|
||||
);
|
||||
providerRequestId = result.lastRequestId;
|
||||
|
||||
Reference in new issue
Block a user