diff --git a/src/main/app/ipc-handlers.ts b/src/main/app/ipc-handlers.ts index 04e5acf8..a7033520 100644 --- a/src/main/app/ipc-handlers.ts +++ b/src/main/app/ipc-handlers.ts @@ -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) => ({ diff --git a/src/main/app/task-queue-worker.ts b/src/main/app/task-queue-worker.ts index 02aabfa0..c5d9b6e8 100644 --- a/src/main/app/task-queue-worker.ts +++ b/src/main/app/task-queue-worker.ts @@ -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"); diff --git a/src/main/database/migrations/069-goal-stall.ts b/src/main/database/migrations/069-goal-stall.ts new file mode 100644 index 00000000..d9003552 --- /dev/null +++ b/src/main/database/migrations/069-goal-stall.ts @@ -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')); +`; diff --git a/src/main/database/schema.ts b/src/main/database/schema.ts index 59794b18..9cd074e5 100644 --- a/src/main/database/schema.ts +++ b/src/main/database/schema.ts @@ -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; diff --git a/src/main/services/agent-goal-tools.ts b/src/main/services/agent-goal-tools.ts index b38b18a4..ca4c38a7 100644 --- a/src/main/services/agent-goal-tools.ts +++ b/src/main/services/agent-goal-tools.ts @@ -83,7 +83,7 @@ export async function completeGoalTool(args: GoalCompletionArgs): Promise '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.`, diff --git a/src/preload/index.ts b/src/preload/index.ts index eb308abd..91e2b7e5 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -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), diff --git a/src/renderer/src/i18n/zh.ts b/src/renderer/src/i18n/zh.ts index 11bcd170..28232179 100644 --- a/src/renderer/src/i18n/zh.ts +++ b/src/renderer/src/i18n/zh.ts @@ -31,6 +31,7 @@ export const zh: Record = { "Writing": "写作方式", "superseded": "已被取代", "stalled": "已停滞", + "Resume": "恢复", "Why it did not run": "未运行的原因", "Done tasks do not run": "已完成的任务不能再运行", "A session is already queued or running": "已有会话在排队或运行中", diff --git a/src/renderer/src/pages/HomePage.tsx b/src/renderer/src/pages/HomePage.tsx index 47fcb106..b4a0b6dc 100644 --- a/src/renderer/src/pages/HomePage.tsx +++ b/src/renderer/src/pages/HomePage.tsx @@ -56,7 +56,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {

{renderSummary(pendingApprovals.length, agents.length)}

- + (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 (
{t("Quarter goal")}
@@ -107,6 +115,13 @@ function GoalCard({
+ {goal?.status === "active" && goal.stalledReason && ( +
+ {goal.stalledReason} + +
+ )} + {resumeError &&
{resumeError}
}
); } diff --git a/src/shared/commands/collaboration.ts b/src/shared/commands/collaboration.ts index f9a2ab83..f7d954ae 100644 --- a/src/shared/commands/collaboration.ts +++ b/src/shared/commands/collaboration.ts @@ -93,6 +93,11 @@ export interface ListKnowledgeRevisionsInput { entryId: string; } +export interface ResumeGoalInput { + orgId: string; + goalId: string; +} + export interface DeleteKnowledgeEntryInput { orgId: string; entryId: string; diff --git a/src/shared/desktop-api.ts b/src/shared/desktop-api.ts index ef406cc2..be88dfda 100644 --- a/src/shared/desktop-api.ts +++ b/src/shared/desktop-api.ts @@ -31,6 +31,7 @@ import type { DeleteDocumentInput, DeleteTaskInput, DeleteKnowledgeEntryInput, + ResumeGoalInput, UpdateAutomationRuleInput, UpdateDocumentInput, UpdateTaskInput, @@ -133,6 +134,7 @@ export interface DesktopApi { deleteDocument(input: DeleteDocumentInput): Promise>; createKnowledgeEntry(input: CreateKnowledgeEntryInput): Promise>; deleteKnowledgeEntry(input: DeleteKnowledgeEntryInput): Promise>; + resumeGoal(input: ResumeGoalInput): Promise>; updateKnowledgeEntry(input: UpdateKnowledgeEntryInput): Promise>; listDocumentVersions(input: ListDocumentVersionsInput): Promise>; listKnowledgeRevisions(input: ListKnowledgeRevisionsInput): Promise>; diff --git a/src/shared/domain.ts b/src/shared/domain.ts index 3403811a..e5175bc5 100644 --- a/src/shared/domain.ts +++ b/src/shared/domain.ts @@ -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; } diff --git a/src/shared/ipc.ts b/src/shared/ipc.ts index 5243edc7..8ea0f7b5 100644 --- a/src/shared/ipc.ts +++ b/src/shared/ipc.ts @@ -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", diff --git a/tests/unit/domain-services.test.ts b/tests/unit/domain-services.test.ts index 7d6606ae..4e8d50c2 100644 --- a/tests/unit/domain-services.test.ts +++ b/tests/unit/domain-services.test.ts @@ -24,7 +24,7 @@ describe("domain services", () => { const connection = createConnection(); 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(); }); diff --git a/tests/unit/goal-review-service.test.ts b/tests/unit/goal-review-service.test.ts index ac913207..123d7a0d 100644 --- a/tests/unit/goal-review-service.test.ts +++ b/tests/unit/goal-review-service.test.ts @@ -4,7 +4,7 @@ import { join } from "node:path"; import { describe, expect, test } from "vitest"; import { DatabaseConnection } from "../../src/main/database/connection"; 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"; interface Harness { @@ -58,6 +58,10 @@ function hoursFromNow(hours: number): string { 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 }> { 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 }>; @@ -87,9 +91,12 @@ describe("goal review service", () => { setTaskStatus(h, h.kickoffId, TASK_STATUS.DONE); 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)); 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. 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)); expect(h.connection.transaction(() => spawnGoalReviews(h.connection.db, h.organization))).toHaveLength(1); 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(); });