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 { importStagedSkills, stageBuiltinSkillsLibrary } from "../services/builtin-skills";
|
||||||
import { coalesce } from "./coalesce";
|
import { coalesce } from "./coalesce";
|
||||||
import { normalizeExternalBrowserUrl } from "./external-url";
|
import { normalizeExternalBrowserUrl } from "./external-url";
|
||||||
|
import { resumeGoal } from "../services/goal-review-service";
|
||||||
|
|
||||||
export interface IpcDeps {
|
export interface IpcDeps {
|
||||||
connection: DatabaseConnection;
|
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.deleteDocument, (_event, input) => mutate(() => documents.delete(input)));
|
||||||
ipcMain.handle(IPC_CHANNELS.createKnowledgeEntry, (_event, input) => mutate(() => knowledge.create(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.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.updateKnowledgeEntry, (_event, input) => mutate(() => knowledge.update(input)));
|
||||||
ipcMain.handle(IPC_CHANNELS.listDocumentVersions, (_event, input) => query("document_versions_failed", () =>
|
ipcMain.handle(IPC_CHANNELS.listDocumentVersions, (_event, input) => query("document_versions_failed", () =>
|
||||||
documents.versions(input.orgId, input.documentId).map((v) => ({
|
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 { IPC_CHANNELS } from "../../shared/ipc";
|
||||||
import { expireStaleChatWakes } from "../services/chat-agent-wake-service";
|
import { expireStaleChatWakes } from "../services/chat-agent-wake-service";
|
||||||
import { OrganizationService } from "../services/organization-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 { TaskExecutionService } from "../services/task-execution-service";
|
||||||
import { runningWorkspaceWriter } from "../services/workspace-write-lock";
|
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
|
// Goal checkpoints first, so a review task created this tick is
|
||||||
// eligible for the same tick's heartbeat enqueue below.
|
// eligible for the same tick's heartbeat enqueue below.
|
||||||
const reviews = input.connection.transaction(() => spawnGoalReviews(input.connection.db, organization));
|
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());
|
const enqueued = input.connection.transaction(() => taskExecution.enqueueHeartbeatRuns());
|
||||||
if (enqueued.length > 0) notifyDataChanged();
|
if (enqueued.length > 0) notifyDataChanged();
|
||||||
dispatch("interactive");
|
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 { taskWritingModeSchema } from "./migrations/066-task-writing-mode";
|
||||||
import { taskRunBudgetSchema } from "./migrations/067-task-run-budget";
|
import { taskRunBudgetSchema } from "./migrations/067-task-run-budget";
|
||||||
import { taskReviewLinkSchema } from "./migrations/068-task-review-link";
|
import { taskReviewLinkSchema } from "./migrations/068-task-review-link";
|
||||||
|
import { goalStallSchema } from "./migrations/069-goal-stall";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -143,6 +144,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ version: 66, name: "task_writing_mode", sql: taskWritingModeSchema },
|
{ version: 66, name: "task_writing_mode", sql: taskWritingModeSchema },
|
||||||
{ version: 67, name: "task_run_budget", sql: taskRunBudgetSchema },
|
{ version: 67, name: "task_run_budget", sql: taskRunBudgetSchema },
|
||||||
{ version: 68, name: "task_review_link", sql: taskReviewLinkSchema },
|
{ version: 68, name: "task_review_link", sql: taskReviewLinkSchema },
|
||||||
|
{ version: 69, name: "goal_stall", sql: goalStallSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||||
|
|||||||
@@ -83,7 +83,7 @@ export async function completeGoalTool(args: GoalCompletionArgs): Promise<string
|
|||||||
if (!verdict.approved) {
|
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.`;
|
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);
|
.run(new Date().toISOString(), goal.id, ctx.orgId);
|
||||||
new AuditService(deps.db).record({
|
new AuditService(deps.db).record({
|
||||||
orgId: ctx.orgId,
|
orgId: ctx.orgId,
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../shared/domain";
|
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../shared/domain";
|
||||||
import { ensureAgentHeartbeat, resolveGoalKickoff } from "./goal-kickoff";
|
import { ensureAgentHeartbeat, resolveGoalKickoff } from "./goal-kickoff";
|
||||||
|
import { AuditService } from "./audit-service";
|
||||||
import type { OrganizationService } from "./organization-service";
|
import type { OrganizationService } from "./organization-service";
|
||||||
|
|
||||||
interface ReviewableGoalRow {
|
interface ReviewableGoalRow {
|
||||||
@@ -11,20 +12,7 @@ interface ReviewableGoalRow {
|
|||||||
owner_agent_id: string | null;
|
owner_agent_id: string | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** When an active goal's board drains after real progress, hands its owner a review: close the goal or plan the next wave. */
|
||||||
* 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.
|
|
||||||
*/
|
|
||||||
export function spawnGoalReviews(db: DatabaseSync, organization: OrganizationService): string[] {
|
export function spawnGoalReviews(db: DatabaseSync, organization: OrganizationService): string[] {
|
||||||
const goals = db.prepare(`
|
const goals = db.prepare(`
|
||||||
select g.id, g.org_id, g.title, g.description, g.owner_agent_id
|
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[] = [];
|
const created: string[] = [];
|
||||||
for (const goal of goals) {
|
for (const goal of goals) {
|
||||||
const plan = resolveGoalKickoff(db, {
|
const taskId = createReviewTask(db, organization, goal);
|
||||||
orgId: goal.org_id,
|
if (taskId) created.push(taskId);
|
||||||
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.`);
|
|
||||||
}
|
}
|
||||||
return created;
|
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 {
|
function goalReviewDescription(goalTitle: string): string {
|
||||||
return [
|
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.`,
|
`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),
|
deleteDocument: (input) => invoke(IPC_CHANNELS.deleteDocument, input),
|
||||||
createKnowledgeEntry: (input) => invoke(IPC_CHANNELS.createKnowledgeEntry, input),
|
createKnowledgeEntry: (input) => invoke(IPC_CHANNELS.createKnowledgeEntry, input),
|
||||||
deleteKnowledgeEntry: (input) => invoke(IPC_CHANNELS.deleteKnowledgeEntry, input),
|
deleteKnowledgeEntry: (input) => invoke(IPC_CHANNELS.deleteKnowledgeEntry, input),
|
||||||
|
resumeGoal: (input) => invoke(IPC_CHANNELS.resumeGoal, input),
|
||||||
updateKnowledgeEntry: (input) => invoke(IPC_CHANNELS.updateKnowledgeEntry, input),
|
updateKnowledgeEntry: (input) => invoke(IPC_CHANNELS.updateKnowledgeEntry, input),
|
||||||
listDocumentVersions: (input) => invoke(IPC_CHANNELS.listDocumentVersions, input),
|
listDocumentVersions: (input) => invoke(IPC_CHANNELS.listDocumentVersions, input),
|
||||||
listKnowledgeRevisions: (input) => invoke(IPC_CHANNELS.listKnowledgeRevisions, input),
|
listKnowledgeRevisions: (input) => invoke(IPC_CHANNELS.listKnowledgeRevisions, input),
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ export const zh: Record<string, string> = {
|
|||||||
"Writing": "写作方式",
|
"Writing": "写作方式",
|
||||||
"superseded": "已被取代",
|
"superseded": "已被取代",
|
||||||
"stalled": "已停滞",
|
"stalled": "已停滞",
|
||||||
|
"Resume": "恢复",
|
||||||
"Why it did not run": "未运行的原因",
|
"Why it did not run": "未运行的原因",
|
||||||
"Done tasks do not run": "已完成的任务不能再运行",
|
"Done tasks do not run": "已完成的任务不能再运行",
|
||||||
"A session is already queued or running": "已有会话在排队或运行中",
|
"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>
|
<p className="page-sub">{renderSummary(pendingApprovals.length, agents.length)}</p>
|
||||||
|
|
||||||
<div style={STRIP}>
|
<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} />
|
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
|
||||||
<WaitingCard
|
<WaitingCard
|
||||||
approvals={pendingApprovals}
|
approvals={pendingApprovals}
|
||||||
@@ -88,12 +88,20 @@ function GoalCard({
|
|||||||
goal,
|
goal,
|
||||||
progress,
|
progress,
|
||||||
organizationName,
|
organizationName,
|
||||||
|
actions,
|
||||||
}: {
|
}: {
|
||||||
goal: GoalRecord | null;
|
goal: GoalRecord | null;
|
||||||
progress: { percent: number; status: "ahead" | "on_track" | "behind" } | null;
|
progress: { percent: number; status: "ahead" | "on_track" | "behind" } | null;
|
||||||
organizationName: string;
|
organizationName: string;
|
||||||
|
actions: PageProps["actions"];
|
||||||
}) {
|
}) {
|
||||||
const percent = progress?.percent ?? 0;
|
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 (
|
return (
|
||||||
<section className="card">
|
<section className="card">
|
||||||
<div className="card-label" style={{ marginBottom: 12 }}>{t("Quarter goal")}</div>
|
<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={{ 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 style={{ width: `${percent}%`, height: "100%", background: "var(--text-1)", transition: "width var(--motion-slow) var(--ease-out)" }} />
|
||||||
</div>
|
</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>
|
</section>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -93,6 +93,11 @@ export interface ListKnowledgeRevisionsInput {
|
|||||||
entryId: string;
|
entryId: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResumeGoalInput {
|
||||||
|
orgId: string;
|
||||||
|
goalId: string;
|
||||||
|
}
|
||||||
|
|
||||||
export interface DeleteKnowledgeEntryInput {
|
export interface DeleteKnowledgeEntryInput {
|
||||||
orgId: string;
|
orgId: string;
|
||||||
entryId: string;
|
entryId: string;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ import type {
|
|||||||
DeleteDocumentInput,
|
DeleteDocumentInput,
|
||||||
DeleteTaskInput,
|
DeleteTaskInput,
|
||||||
DeleteKnowledgeEntryInput,
|
DeleteKnowledgeEntryInput,
|
||||||
|
ResumeGoalInput,
|
||||||
UpdateAutomationRuleInput,
|
UpdateAutomationRuleInput,
|
||||||
UpdateDocumentInput,
|
UpdateDocumentInput,
|
||||||
UpdateTaskInput,
|
UpdateTaskInput,
|
||||||
@@ -133,6 +134,7 @@ export interface DesktopApi {
|
|||||||
deleteDocument(input: DeleteDocumentInput): Promise<DesktopResult<AppSnapshot>>;
|
deleteDocument(input: DeleteDocumentInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
createKnowledgeEntry(input: CreateKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
|
createKnowledgeEntry(input: CreateKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
deleteKnowledgeEntry(input: DeleteKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
|
deleteKnowledgeEntry(input: DeleteKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
|
resumeGoal(input: ResumeGoalInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
updateKnowledgeEntry(input: UpdateKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
|
updateKnowledgeEntry(input: UpdateKnowledgeEntryInput): Promise<DesktopResult<AppSnapshot>>;
|
||||||
listDocumentVersions(input: ListDocumentVersionsInput): Promise<DesktopResult<DocumentVersionRecord[]>>;
|
listDocumentVersions(input: ListDocumentVersionsInput): Promise<DesktopResult<DocumentVersionRecord[]>>;
|
||||||
listKnowledgeRevisions(input: ListKnowledgeRevisionsInput): Promise<DesktopResult<KnowledgeRevisionRecord[]>>;
|
listKnowledgeRevisions(input: ListKnowledgeRevisionsInput): Promise<DesktopResult<KnowledgeRevisionRecord[]>>;
|
||||||
|
|||||||
@@ -126,6 +126,8 @@ export interface GoalRecord {
|
|||||||
description: string;
|
description: string;
|
||||||
status: string;
|
status: string;
|
||||||
ownerAgentId: string | null;
|
ownerAgentId: string | null;
|
||||||
|
/** Set when the goal's review loop stopped without a decision. */
|
||||||
|
stalledReason: string | null;
|
||||||
createdAt: string;
|
createdAt: string;
|
||||||
updatedAt: string;
|
updatedAt: string;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -68,6 +68,7 @@ export const IPC_CHANNELS = {
|
|||||||
deleteDocument: "agentcompany:delete-document",
|
deleteDocument: "agentcompany:delete-document",
|
||||||
createKnowledgeEntry: "agentcompany:create-knowledge-entry",
|
createKnowledgeEntry: "agentcompany:create-knowledge-entry",
|
||||||
deleteKnowledgeEntry: "agentcompany:delete-knowledge-entry",
|
deleteKnowledgeEntry: "agentcompany:delete-knowledge-entry",
|
||||||
|
resumeGoal: "agentcompany:resume-goal",
|
||||||
updateKnowledgeEntry: "agentcompany:update-knowledge-entry",
|
updateKnowledgeEntry: "agentcompany:update-knowledge-entry",
|
||||||
listDocumentVersions: "agentcompany:list-document-versions",
|
listDocumentVersions: "agentcompany:list-document-versions",
|
||||||
listKnowledgeRevisions: "agentcompany:list-knowledge-revisions",
|
listKnowledgeRevisions: "agentcompany:list-knowledge-revisions",
|
||||||
|
|||||||
@@ -24,7 +24,7 @@ describe("domain services", () => {
|
|||||||
const connection = createConnection();
|
const connection = createConnection();
|
||||||
|
|
||||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68]);
|
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69]);
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { join } from "node:path";
|
|||||||
import { describe, expect, test } from "vitest";
|
import { describe, expect, test } from "vitest";
|
||||||
import { DatabaseConnection } from "../../src/main/database/connection";
|
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||||
import { OrganizationService } from "../../src/main/services/organization-service";
|
import { OrganizationService } from "../../src/main/services/organization-service";
|
||||||
import { spawnGoalReviews } from "../../src/main/services/goal-review-service";
|
import { markStalledGoals, resumeGoal, spawnGoalReviews } from "../../src/main/services/goal-review-service";
|
||||||
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain";
|
import { RISK_LEVEL, TASK_PRIORITY, TASK_STATUS } from "../../src/shared/domain";
|
||||||
|
|
||||||
interface Harness {
|
interface Harness {
|
||||||
@@ -58,6 +58,10 @@ function hoursFromNow(hours: number): string {
|
|||||||
return new Date(Date.now() + hours * 3_600_000).toISOString();
|
return new Date(Date.now() + hours * 3_600_000).toISOString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function stalledReason(h: Harness): string | null {
|
||||||
|
return (h.connection.db.prepare("select stalled_reason from goals where id = ?").get(h.goalId) as { stalled_reason: string | null }).stalled_reason;
|
||||||
|
}
|
||||||
|
|
||||||
function reviews(h: Harness): Array<{ id: string; status: string }> {
|
function reviews(h: Harness): Array<{ id: string; status: string }> {
|
||||||
return h.connection.db.prepare("select id, status from tasks where goal_id = ? and origin = 'goal_review' order by created_at")
|
return h.connection.db.prepare("select id, status from tasks where goal_id = ? and origin = 'goal_review' order by created_at")
|
||||||
.all(h.goalId) as Array<{ id: string; status: string }>;
|
.all(h.goalId) as Array<{ id: string; status: string }>;
|
||||||
@@ -87,9 +91,12 @@ describe("goal review service", () => {
|
|||||||
setTaskStatus(h, h.kickoffId, TASK_STATUS.DONE);
|
setTaskStatus(h, h.kickoffId, TASK_STATUS.DONE);
|
||||||
const [reviewId] = h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization));
|
const [reviewId] = h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization));
|
||||||
|
|
||||||
// CEO closes the review without creating tasks or completing the goal: silence, not a loop.
|
// CEO closes the review without creating tasks or completing the goal: no loop, but a visible stall.
|
||||||
setTaskStatus(h, reviewId, TASK_STATUS.DONE, hoursFromNow(1));
|
setTaskStatus(h, reviewId, TASK_STATUS.DONE, hoursFromNow(1));
|
||||||
expect(h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization))).toEqual([]);
|
expect(h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization))).toEqual([]);
|
||||||
|
expect(markStalledGoals(h.connection.db)).toBe(1);
|
||||||
|
expect(markStalledGoals(h.connection.db)).toBe(0);
|
||||||
|
expect(stalledReason(h)).toBe("Stalled: the last goal review neither closed the goal nor planned new work. Resume it to run a fresh review.");
|
||||||
|
|
||||||
// Chatter never counts as progress.
|
// Chatter never counts as progress.
|
||||||
addDoneTask(h, "Reply to CEO", "chat_wake", hoursFromNow(2));
|
addDoneTask(h, "Reply to CEO", "chat_wake", hoursFromNow(2));
|
||||||
@@ -99,6 +106,23 @@ describe("goal review service", () => {
|
|||||||
addDoneTask(h, "Implement tenant module", null, hoursFromNow(3));
|
addDoneTask(h, "Implement tenant module", null, hoursFromNow(3));
|
||||||
expect(h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization))).toHaveLength(1);
|
expect(h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization))).toHaveLength(1);
|
||||||
expect(reviews(h)).toHaveLength(2);
|
expect(reviews(h)).toHaveLength(2);
|
||||||
|
expect(stalledReason(h)).toBeNull();
|
||||||
|
h.connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a review that spends its run budget stalls the goal; resuming closes it and starts a fresh review", () => {
|
||||||
|
const h = createHarness();
|
||||||
|
setTaskStatus(h, h.kickoffId, TASK_STATUS.DONE);
|
||||||
|
const [reviewId] = h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization));
|
||||||
|
h.connection.db.prepare("update tasks set stalled_reason = 'Stalled: 3 sessions' where id = ?").run(reviewId);
|
||||||
|
expect(markStalledGoals(h.connection.db)).toBe(1);
|
||||||
|
expect(stalledReason(h)).toBe("Stalled: the goal review spent its run budget without a decision. Resume it to run a fresh review.");
|
||||||
|
expect(h.connection.db.prepare("select count(*) as n from audit_events where entity_id = ? and action = 'stalled'").get(h.goalId)).toEqual({ n: 1 });
|
||||||
|
|
||||||
|
const fresh = h.connection.transaction(() => resumeGoal(h.connection.db, h.organization, h.orgId, h.goalId));
|
||||||
|
expect(reviews(h)).toEqual([{ id: reviewId, status: "done" }, { id: fresh, status: "backlog" }]);
|
||||||
|
expect(stalledReason(h)).toBeNull();
|
||||||
|
expect(() => resumeGoal(h.connection.db, h.organization, h.orgId, h.goalId)).toThrow('Goal "Ship the platform" is not stalled.');
|
||||||
h.connection.close();
|
h.connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user