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:
1 parent
69e991ff93
commit
ec2698575d
15 files changed
+149
-45
No files matched your search
@@ -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,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'));
|
||||
`;
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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.`,
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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": "已有会话在排队或运行中",
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -93,6 +93,11 @@ export interface ListKnowledgeRevisionsInput {
|
||||
entryId: string;
|
||||
}
|
||||
|
||||
export interface ResumeGoalInput {
|
||||
orgId: string;
|
||||
goalId: string;
|
||||
}
|
||||
|
||||
export interface DeleteKnowledgeEntryInput {
|
||||
orgId: string;
|
||||
entryId: string;
|
||||
|
||||
@@ -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[]>>;
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user