Q53: stalled goal reviews leave a visible state and a resume path

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-08 09:25:46 -04:00
1 parent 69e991ff93
commit ec2698575d
15 files changed
+149 -45

No files matched your search

+2
View File
@@ -32,6 +32,7 @@ import { IntegrationKeyStore } from "../services/integration-key-store";
import { importStagedSkills, stageBuiltinSkillsLibrary } from "../services/builtin-skills";
import { coalesce } from "./coalesce";
import { normalizeExternalBrowserUrl } from "./external-url";
import { resumeGoal } from "../services/goal-review-service";
export interface IpcDeps {
connection: DatabaseConnection;
@@ -171,6 +172,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
ipcMain.handle(IPC_CHANNELS.deleteDocument, (_event, input) => mutate(() => documents.delete(input)));
ipcMain.handle(IPC_CHANNELS.createKnowledgeEntry, (_event, input) => mutate(() => knowledge.create(input)));
ipcMain.handle(IPC_CHANNELS.deleteKnowledgeEntry, (_event, input) => mutate(() => knowledge.delete(input)));
ipcMain.handle(IPC_CHANNELS.resumeGoal, (_event, input) => mutate(() => resumeGoal(deps.connection.db, organization, input.orgId, input.goalId)));
ipcMain.handle(IPC_CHANNELS.updateKnowledgeEntry, (_event, input) => mutate(() => knowledge.update(input)));
ipcMain.handle(IPC_CHANNELS.listDocumentVersions, (_event, input) => query("document_versions_failed", () =>
documents.versions(input.orgId, input.documentId).map((v) => ({
+3 -2
View File
@@ -3,7 +3,7 @@ import type { DatabaseConnection } from "../database/connection";
import { IPC_CHANNELS } from "../../shared/ipc";
import { expireStaleChatWakes } from "../services/chat-agent-wake-service";
import { OrganizationService } from "../services/organization-service";
import { spawnGoalReviews } from "../services/goal-review-service";
import { markStalledGoals, spawnGoalReviews } from "../services/goal-review-service";
import { TaskExecutionService } from "../services/task-execution-service";
import { runningWorkspaceWriter } from "../services/workspace-write-lock";
@@ -132,7 +132,8 @@ export function startTaskQueueWorker(input: TaskQueueWorkerInput): () => void {
// Goal checkpoints first, so a review task created this tick is
// eligible for the same tick's heartbeat enqueue below.
const reviews = input.connection.transaction(() => spawnGoalReviews(input.connection.db, organization));
if (reviews.length > 0) notifyDataChanged();
const stalledGoals = input.connection.transaction(() => markStalledGoals(input.connection.db));
if (reviews.length > 0 || stalledGoals > 0) notifyDataChanged();
const enqueued = input.connection.transaction(() => taskExecution.enqueueHeartbeatRuns());
if (enqueued.length > 0) notifyDataChanged();
dispatch("interactive");
@@ -0,0 +1,6 @@
export const goalStallSchema = `
alter table goals add column stalled_reason text;
insert into schema_migrations(version, name, applied_at)
values (69, 'goal_stall', datetime('now'));
`;
+2
View File
@@ -67,6 +67,7 @@ import { documentVersionsSchema } from "./migrations/065-document-versions";
import { taskWritingModeSchema } from "./migrations/066-task-writing-mode";
import { taskRunBudgetSchema } from "./migrations/067-task-run-budget";
import { taskReviewLinkSchema } from "./migrations/068-task-review-link";
import { goalStallSchema } from "./migrations/069-goal-stall";
interface Migration {
version: number;
@@ -143,6 +144,7 @@ const MIGRATIONS: Migration[] = [
{ version: 66, name: "task_writing_mode", sql: taskWritingModeSchema },
{ version: 67, name: "task_run_budget", sql: taskRunBudgetSchema },
{ version: 68, name: "task_review_link", sql: taskReviewLinkSchema },
{ version: 69, name: "goal_stall", sql: goalStallSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
+1 -1
View File
@@ -83,7 +83,7 @@ export async function completeGoalTool(args: GoalCompletionArgs): Promise<string
if (!verdict.approved) {
return `The independent audit rejected closing the goal: ${verdict.reason} The goal stays active — close that gap or create the tasks that will.`;
}
deps.db.prepare("update goals set status = 'achieved', updated_at = ? where id = ? and org_id = ?")
deps.db.prepare("update goals set status = 'achieved', stalled_reason = null, updated_at = ? where id = ? and org_id = ?")
.run(new Date().toISOString(), goal.id, ctx.orgId);
new AuditService(deps.db).record({
orgId: ctx.orgId,
+80 -38
View File
@@ -1,6 +1,7 @@
import type { DatabaseSync } from "node:sqlite";
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../shared/domain";
import { ensureAgentHeartbeat, resolveGoalKickoff } from "./goal-kickoff";
import { AuditService } from "./audit-service";
import type { OrganizationService } from "./organization-service";
interface ReviewableGoalRow {
@@ -11,20 +12,7 @@ interface ReviewableGoalRow {
owner_agent_id: string | null;
}
/**
* Closes the loop the task pipeline leaves open: tasks end at `done`, but a
* company is finished when its GOAL is achieved. Whenever an active goal has
* no open tasks left and real work landed since the last checkpoint, the goal
* owner (usually the CEO) gets one review task: verify the goal against the
* actual state of the workspace, then either mark it achieved (complete_goal)
* or plan the next wave of tasks. Without this, the company drains its board,
* declares nothing, and idles forever under a still-active goal.
*
* Termination is structural: chatter (`chat_wake`) and reviews themselves
* never count as progress, so a review that ends with no new work and no
* completed goal does NOT respawn — the loop only continues while real tasks
* keep completing or the goal is closed.
*/
/** When an active goal's board drains after real progress, hands its owner a review: close the goal or plan the next wave. */
export function spawnGoalReviews(db: DatabaseSync, organization: OrganizationService): string[] {
const goals = db.prepare(`
select g.id, g.org_id, g.title, g.description, g.owner_agent_id
@@ -55,34 +43,88 @@ export function spawnGoalReviews(db: DatabaseSync, organization: OrganizationSer
const created: string[] = [];
for (const goal of goals) {
const plan = resolveGoalKickoff(db, {
orgId: goal.org_id,
goalTitle: goal.title,
goalDescription: goal.description,
ownerAgentId: goal.owner_agent_id,
});
if (!plan) continue;
ensureAgentHeartbeat(db, goal.org_id, plan.assigneeAgentId);
const taskId = organization.createTask({
orgId: goal.org_id,
title: `Goal review: ${goal.title.trim()}`,
description: goalReviewDescription(goal.title),
status: TASK_STATUS.BACKLOG,
assigneeAgentId: plan.assigneeAgentId,
risk: RISK_LEVEL.MEDIUM,
projectId: null,
goalId: goal.id,
workspaceId: plan.workspaceId,
priority: TASK_PRIORITY.HIGH,
requiresFilesystem: plan.workspaceId !== null,
origin: "goal_review",
});
created.push(taskId);
console.info(`[goal-review] all tasks for goal "${goal.title}" are finished; created a review checkpoint for the goal owner.`);
const taskId = createReviewTask(db, organization, goal);
if (taskId) created.push(taskId);
}
return created;
}
/** Closes a stalled goal's stuck review and starts a fresh one; returns the new review task id. */
export function resumeGoal(db: DatabaseSync, organization: OrganizationService, orgId: string, goalId: string): string {
const goal = db.prepare("select id, org_id, title, description, owner_agent_id, stalled_reason from goals where id = ? and org_id = ? and status = 'active'")
.get(goalId, orgId) as (ReviewableGoalRow & { stalled_reason: string | null }) | undefined;
if (!goal) throw new Error("Active goal was not found.");
if (!goal.stalled_reason) throw new Error(`Goal "${goal.title}" is not stalled.`);
db.prepare("update tasks set status = 'done', updated_at = ? where goal_id = ? and origin = 'goal_review' and status <> 'done'")
.run(new Date().toISOString(), goalId);
const taskId = createReviewTask(db, organization, goal);
if (!taskId) throw new Error(`Goal "${goal.title}" has no active agent to review it.`);
new AuditService(db).record({ orgId, entityType: "goal", entityId: goalId, actorType: "employee", actorId: null, action: "resumed", payload: { reviewTaskId: taskId } });
return taskId;
}
function createReviewTask(db: DatabaseSync, organization: OrganizationService, goal: ReviewableGoalRow): string | null {
const plan = resolveGoalKickoff(db, {
orgId: goal.org_id,
goalTitle: goal.title,
goalDescription: goal.description,
ownerAgentId: goal.owner_agent_id,
});
if (!plan) return null;
ensureAgentHeartbeat(db, goal.org_id, plan.assigneeAgentId);
const taskId = organization.createTask({
orgId: goal.org_id,
title: `Goal review: ${goal.title.trim()}`,
description: goalReviewDescription(goal.title),
status: TASK_STATUS.BACKLOG,
assigneeAgentId: plan.assigneeAgentId,
risk: RISK_LEVEL.MEDIUM,
projectId: null,
goalId: goal.id,
workspaceId: plan.workspaceId,
priority: TASK_PRIORITY.HIGH,
requiresFilesystem: plan.workspaceId !== null,
origin: "goal_review",
});
db.prepare("update goals set stalled_reason = null where id = ?").run(goal.id);
console.info(`[goal-review] created a review checkpoint for goal "${goal.title}".`);
return taskId;
}
/** Gives an active goal a visible stalled_reason when its review loop can no longer move on its own; returns how many stalled now. */
export function markStalledGoals(db: DatabaseSync): number {
const stalls = db.prepare(`
select g.id, g.org_id, g.title,
case when exists (
select 1 from tasks r where r.goal_id = g.id and r.origin = 'goal_review' and r.status <> 'done' and r.stalled_reason is not null
) then 'budget' else 'silent' end as kind
from goals g
where g.status = 'active' and g.stalled_reason is null
and (
exists (select 1 from tasks r where r.goal_id = g.id and r.origin = 'goal_review' and r.status <> 'done' and r.stalled_reason is not null)
or (
not exists (select 1 from tasks t where t.goal_id = g.id and t.status in ('backlog', 'in_progress', 'review'))
and exists (select 1 from tasks r where r.goal_id = g.id and r.origin = 'goal_review')
and not exists (
select 1 from tasks t
where t.goal_id = g.id and t.status = 'done' and t.origin is null
and t.updated_at > (select max(r.created_at) from tasks r where r.goal_id = g.id and r.origin = 'goal_review')
)
)
)
`).all() as Array<{ id: string; org_id: string; title: string; kind: "budget" | "silent" }>;
const audit = new AuditService(db);
for (const goal of stalls) {
const reason = goal.kind === "budget"
? "Stalled: the goal review spent its run budget without a decision. Resume it to run a fresh review."
: "Stalled: the last goal review neither closed the goal nor planned new work. Resume it to run a fresh review.";
db.prepare("update goals set stalled_reason = ? where id = ?").run(reason, goal.id);
console.warn(`[goal-review] goal "${goal.title}" ${reason}`);
audit.record({ orgId: goal.org_id, entityType: "goal", entityId: goal.id, actorType: "system", actorId: null, action: "stalled", payload: { reason } });
}
return stalls.length;
}
function goalReviewDescription(goalTitle: string): string {
return [
`Every task under the goal "${goalTitle.trim()}" is finished, but the goal itself is still open. Decide what happens next — this is a checkpoint, not a formality.`,
+1
View File
@@ -32,6 +32,7 @@ const api: DesktopApi = {
deleteDocument: (input) => invoke(IPC_CHANNELS.deleteDocument, input),
createKnowledgeEntry: (input) => invoke(IPC_CHANNELS.createKnowledgeEntry, input),
deleteKnowledgeEntry: (input) => invoke(IPC_CHANNELS.deleteKnowledgeEntry, input),
resumeGoal: (input) => invoke(IPC_CHANNELS.resumeGoal, input),
updateKnowledgeEntry: (input) => invoke(IPC_CHANNELS.updateKnowledgeEntry, input),
listDocumentVersions: (input) => invoke(IPC_CHANNELS.listDocumentVersions, input),
listKnowledgeRevisions: (input) => invoke(IPC_CHANNELS.listKnowledgeRevisions, input),
+1
View File
@@ -31,6 +31,7 @@ export const zh: Record<string, string> = {
"Writing": "写作方式",
"superseded": "已被取代",
"stalled": "已停滞",
"Resume": "恢复",
"Why it did not run": "未运行的原因",
"Done tasks do not run": "已完成的任务不能再运行",
"A session is already queued or running": "已有会话在排队或运行中",
+16 -1
View File
@@ -56,7 +56,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
<p className="page-sub">{renderSummary(pendingApprovals.length, agents.length)}</p>
<div style={STRIP}>
<GoalCard goal={primaryGoal} progress={goalProgress} organizationName={organization.name} />
<GoalCard goal={primaryGoal} progress={goalProgress} organizationName={organization.name} actions={actions} />
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
<WaitingCard
approvals={pendingApprovals}
@@ -88,12 +88,20 @@ function GoalCard({
goal,
progress,
organizationName,
actions,
}: {
goal: GoalRecord | null;
progress: { percent: number; status: "ahead" | "on_track" | "behind" } | null;
organizationName: string;
actions: PageProps["actions"];
}) {
const percent = progress?.percent ?? 0;
const [resumeError, setResumeError] = useState<string | null>(null);
const resume = (stalled: GoalRecord) => {
setResumeError(null);
actions.run(() => window.agentCompany.resumeGoal({ orgId: stalled.orgId, goalId: stalled.id }))
.catch((error: unknown) => setResumeError(error instanceof Error ? error.message : String(error)));
};
return (
<section className="card">
<div className="card-label" style={{ marginBottom: 12 }}>{t("Quarter goal")}</div>
@@ -107,6 +115,13 @@ function GoalCard({
<div style={{ marginTop: 12, height: 4, background: "var(--panel-3)", borderRadius: 999, overflow: "hidden" }}>
<div style={{ width: `${percent}%`, height: "100%", background: "var(--text-1)", transition: "width var(--motion-slow) var(--ease-out)" }} />
</div>
{goal?.status === "active" && goal.stalledReason && (
<div className="row" style={{ marginTop: 12, gap: 8, alignItems: "center" }}>
<span className="form-error" role="status" style={{ flex: 1, marginTop: 0 }}>{goal.stalledReason}</span>
<Button size="small" onClick={() => resume(goal)}>{t("Resume")}</Button>
</div>
)}
{resumeError && <div className="form-error" role="alert">{resumeError}</div>}
</section>
);
}
+5
View File
@@ -93,6 +93,11 @@ export interface ListKnowledgeRevisionsInput {
entryId: string;
}
export interface ResumeGoalInput {
orgId: string;
goalId: string;
}
export interface DeleteKnowledgeEntryInput {
orgId: string;
entryId: string;
+2
View File
@@ -31,6 +31,7 @@ import type {
DeleteDocumentInput,
DeleteTaskInput,
DeleteKnowledgeEntryInput,
ResumeGoalInput,
UpdateAutomationRuleInput,
UpdateDocumentInput,
UpdateTaskInput,
@@ -133,6 +134,7 @@ export interface DesktopApi {
deleteDocument(input: DeleteDocumentInput): Promise<DesktopResult<AppSnapshot>>;
createKnowledgeEntry(input: CreateKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
deleteKnowledgeEntry(input: DeleteKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
resumeGoal(input: ResumeGoalInput): Promise<DesktopResult<AppSnapshot>>;
updateKnowledgeEntry(input: UpdateKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
listDocumentVersions(input: ListDocumentVersionsInput): Promise<DesktopResult<DocumentVersionRecord[]>>;
listKnowledgeRevisions(input: ListKnowledgeRevisionsInput): Promise<DesktopResult<KnowledgeRevisionRecord[]>>;
+2
View File
@@ -126,6 +126,8 @@ export interface GoalRecord {
description: string;
status: string;
ownerAgentId: string | null;
/** Set when the goal's review loop stopped without a decision. */
stalledReason: string | null;
createdAt: string;
updatedAt: string;
}
+1
View File
@@ -68,6 +68,7 @@ export const IPC_CHANNELS = {
deleteDocument: "agentcompany:delete-document",
createKnowledgeEntry: "agentcompany:create-knowledge-entry",
deleteKnowledgeEntry: "agentcompany:delete-knowledge-entry",
resumeGoal: "agentcompany:resume-goal",
updateKnowledgeEntry: "agentcompany:update-knowledge-entry",
listDocumentVersions: "agentcompany:list-document-versions",
listKnowledgeRevisions: "agentcompany:list-knowledge-revisions",