Q160: a goal's pace is measured against its target date, and claimed only when it has one

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
ZacharyZhang-NYandClaude Opus 5.5 committed 2026-10-09 02:44:20 -04:00
1 parent 459dcf0ea2
commit c9117fe46d
18 files changed
+172 -24

No files matched your search

+2 -1
View File
@@ -54,7 +54,8 @@ export const organizationContracts = {
]),
updateTaskStatus: z.strictObject({ orgId: id, taskId: id, status: z.enum(TASK_STATUS) }) satisfies z.ZodType<C.UpdateTaskStatusInput>,
runTask: z.strictObject({ orgId: id, taskId: id }) satisfies z.ZodType<C.RunTaskInput>,
createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId }) satisfies z.ZodType<C.CreateGoalInput>,
createGoal: z.strictObject({ orgId: id, title: z.string(), description: z.string(), ownerAgentId: nullableId, dueAt: z.string().nullable().optional() }) satisfies z.ZodType<C.CreateGoalInput>,
setGoalDueDate: z.strictObject({ orgId: id, goalId: id, dueAt: z.string().nullable() }) satisfies z.ZodType<C.SetGoalDueDateInput>,
resumeGoal: z.strictObject({ orgId: id, goalId: id }) satisfies z.ZodType<C.ResumeGoalInput>,
createProject: z.strictObject({
orgId: id, goalId: nullableId, name: z.string(), description: z.string(), leadAgentId: nullableId,
+1
View File
@@ -183,6 +183,7 @@ export function registerIpcHandlers(deps: IpcDeps): void {
}
});
handle("createGoal", (input) => mutate(() => organization.createGoal(input)));
handle("setGoalDueDate", (input) => mutate(() => organization.setGoalDueDate(input)));
handle("createProject", (input) => mutate(() => organization.createProject(input)));
handle("createWorkspace", (input) => mutate(() => organization.createWorkspace(input)));
handle("createDocument", (input) => mutate(() => documents.create(input)));
@@ -0,0 +1,7 @@
// When a goal is planned to be reached; without it, progress is reported but no pace is claimed.
export const goalDueDateSchema = `
alter table goals add column due_at text;
insert into schema_migrations(version, name, applied_at)
values (102, 'goal_due_date', datetime('now'));
`;
+2
View File
@@ -100,6 +100,7 @@ import { dropAuditReplaysSchema } from "./migrations/098-drop-audit-replays";
import { dropRollbackRecordsSchema } from "./migrations/099-drop-rollback-records";
import { chatReplyRequestsSchema } from "./migrations/100-chat-reply-requests";
import { taskBlockedSchema } from "./migrations/101-task-blocked";
import { goalDueDateSchema } from "./migrations/102-goal-due-date";
interface Migration {
version: number;
@@ -209,6 +210,7 @@ const MIGRATIONS: Migration[] = [
{ version: 99, name: "drop_rollback_records", sql: dropRollbackRecordsSchema },
{ version: 100, name: "chat_reply_requests", sql: chatReplyRequestsSchema },
{ version: 101, name: "task_blocked", sql: taskBlockedSchema },
{ version: 102, name: "goal_due_date", sql: goalDueDateSchema },
];
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
+22 -4
View File
@@ -2,7 +2,7 @@ import type { TaskDeleteImpact } from "../../shared/domain";
import type { DatabaseSync } from "node:sqlite";
import { APPROVAL_STATE, RISK_LEVEL, TASK_STATUS, WRITING_MODE, type RiskLevel } from "../../shared/domain";
import { createId } from "../../shared/ids";
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, SetTaskBlockedInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
import type { CreateAgentInput, CreateGoalInput, CreateOrganizationInput, CreateProjectInput, CreateTaskInput, CreateWorkspaceInput, DeleteTaskInput, SetGoalDueDateInput, SetTaskBlockedInput, UpdateAgentInput, UpdateEmployeeProfileInput, UpdateTaskInput } from "../../shared/commands";
import { AuditService } from "./audit-service";
import { ensureAgentHeartbeat, resolveGoalKickoff, type GoalKickoffPlan } from "./goal-kickoff";
import { PolicyService } from "./policy-service";
@@ -359,9 +359,9 @@ export class OrganizationService {
const now = new Date().toISOString();
if (input.ownerAgentId) this.verifyOptionalOrgEntity("agents", input.ownerAgentId, input.orgId, "Owner agent");
this.db.prepare(`
insert into goals(id, org_id, title, description, status, owner_agent_id, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.title.trim(), input.description.trim(), "active", input.ownerAgentId, now, now);
insert into goals(id, org_id, title, description, status, owner_agent_id, due_at, created_at, updated_at)
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run(id, input.orgId, input.title.trim(), input.description.trim(), "active", input.ownerAgentId, validDueAt(input.dueAt ?? null), now, now);
this.audit.record({
orgId: input.orgId,
entityType: "goal",
@@ -477,6 +477,14 @@ export class OrganizationService {
return this.tasks.deleteImpact(orgId, taskId);
}
/** Sets or clears when a goal is planned to be reached. */
setGoalDueDate(input: SetGoalDueDateInput): void {
const result = this.db.prepare("update goals set due_at = ?, updated_at = ? where id = ? and org_id = ?")
.run(validDueAt(input.dueAt), new Date().toISOString(), input.goalId, input.orgId);
if (result.changes === 0) throw new Error("Goal was not found.");
this.audit.record({ orgId: input.orgId, entityType: "goal", entityId: input.goalId, actorType: "employee", actorId: null, action: "due_date_set", payload: { dueAt: input.dueAt } });
}
setTaskBlocked(input: SetTaskBlockedInput): void {
this.tasks.setBlocked(input);
}
@@ -493,3 +501,13 @@ export class OrganizationService {
return this.tasks.agentSetStatus(input);
}
}
/** A target date is an exact ISO UTC time on a real calendar day; anything else is refused rather than stored shifted. */
function validDueAt(dueAt: string | null): string | null {
if (dueAt === null) return null;
const exact = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d{3})?Z$/.test(dueAt);
const time = new Date(dueAt);
const normalized = dueAt.length === 20 ? dueAt.replace("Z", ".000Z") : dueAt;
if (!exact || Number.isNaN(time.getTime()) || time.toISOString() !== normalized) throw new Error(`"${dueAt}" is not a date.`);
return normalized;
}
+1
View File
@@ -94,6 +94,7 @@ const api: DesktopApi = {
taskDeleteImpact: (input) => invoke(IPC_CHANNELS.taskDeleteImpact, input),
cancelQueuedRun: (input) => invoke(IPC_CHANNELS.cancelQueuedRun, input),
runHistory: (input) => invoke(IPC_CHANNELS.runHistory, input),
setGoalDueDate: (input) => invoke(IPC_CHANNELS.setGoalDueDate, input),
revokeConnector: (input) => invoke(IPC_CHANNELS.revokeConnector, input),
refreshComposioAccounts: (input) => invoke(IPC_CHANNELS.refreshComposioAccounts, input),
search: (input) => invoke(IPC_CHANNELS.search, input),
+3
View File
@@ -53,6 +53,9 @@ export const zh: Record<string, string> = {
"Show earlier decisions": "显示更早的决定",
"Cancel run": "取消运行",
"Show older runs": "显示更早的运行",
"Target date": "目标日期",
"no target date": "未设目标日期",
"overdue": "已逾期",
"These are all the runs.": "已显示全部运行。",
"Running…": "运行中…",
"A cancelled session can start again on the next heartbeat; block the task to keep it stopped.": "取消的会话可能在下一次心跳时再次开始;如需保持停止,请阻塞该任务。",
+35 -18
View File
@@ -1,3 +1,4 @@
import { goalProgress, type GoalPace } from "./home/goal-progress";
import { newestRuns } from "./runs/newest-runs";
import { isBusinessTask } from "./work/task-helpers";
import { useMemo, useState, type CSSProperties } from "react";
@@ -47,7 +48,7 @@ export function HomePage({ snapshot, actions, navigate }: PageProps) {
const pendingApprovals = approvals.filter((approval) => approval.state === "pending");
const totalBudgetCents = agents.reduce((sum, agent) => sum + agent.budgetCents, 0);
const primaryGoal = goals.find((goal) => goal.status === "active") ?? goals[0] ?? null;
const goalProgress = computeGoalProgress(primaryGoal, businessTasks);
const progress = primaryGoal ? goalProgress(primaryGoal, businessTasks, new Date()) : null;
const firstName = employee.displayName.split(/\s+/)[0] ?? employee.displayName;
const greeting = greetingFor(new Date().getHours());
@@ -60,7 +61,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} actions={actions} />
<GoalCard goal={primaryGoal} progress={progress} organizationName={organization.name} actions={actions} />
<SpendCard providers={providers} totalBudgetCents={totalBudgetCents} capCents={budgetCapCents} agents={agents} />
<WaitingCard
approvals={pendingApprovals}
@@ -95,7 +96,7 @@ function GoalCard({
actions,
}: {
goal: GoalRecord | null;
progress: { percent: number; status: "ahead" | "on_track" | "behind" } | null;
progress: { percent: number; pace: GoalPace | null } | null;
organizationName: string;
actions: PageProps["actions"];
}) {
@@ -114,7 +115,8 @@ function GoalCard({
</div>
<div className="row" style={{ alignItems: "baseline", gap: 8, marginTop: 16 }}>
<span className="num" style={{ fontSize: 18, fontWeight: 600 }}>{progress ? `${percent}%` : "—"}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{progress ? statusLabel(progress.status) : t("no work yet")}</span>
<span style={{ fontSize: 12, color: "var(--text-3)" }}>{progress?.pace ? statusLabel(progress.pace) : t("no target date")}</span>
{goal && <TargetDate goal={goal} actions={actions} />}
</div>
<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)" }} />
@@ -299,20 +301,10 @@ function RecentMessagesCard({
);
}
function computeGoalProgress(goal: GoalRecord | null, tasks: TaskRecord[]): { percent: number; status: "ahead" | "on_track" | "behind" } | null {
if (!goal) return null;
const scoped = tasks.filter((task) => task.goalId === goal.id);
if (scoped.length === 0) return { percent: 0, status: "on_track" };
const done = scoped.filter((task) => task.status === "done").length;
const percent = Math.round((done / scoped.length) * 100);
if (percent >= 75) return { percent, status: "ahead" };
if (percent >= 30) return { percent, status: "on_track" };
return { percent, status: "behind" };
}
function statusLabel(status: "ahead" | "on_track" | "behind"): string {
if (status === "ahead") return t("ahead");
if (status === "behind") return t("behind");
function statusLabel(pace: GoalPace): string {
if (pace === "ahead") return t("ahead");
if (pace === "behind") return t("behind");
if (pace === "overdue") return t("overdue");
return t("on track");
}
@@ -367,3 +359,28 @@ function runStateLabel(state: string): string {
if (state === "stopped") return t("Stopped");
return state;
}
/** Sets or clears when the goal should be reached, which is what its pace is measured against. */
function TargetDate({ goal, actions }: { goal: GoalRecord; actions: PageProps["actions"] }) {
const [error, setError] = useState<string | null>(null);
const value = goal.dueAt ? localDate(new Date(goal.dueAt)) : "";
const change = (date: string) => {
setError(null);
// A date means the end of that day where the operator is.
const dueAt = date ? new Date(`${date}T23:59:59`).toISOString() : null;
actions.run(() => window.agentCompany.setGoalDueDate({ orgId: goal.orgId, goalId: goal.id, dueAt }))
.catch((reason: unknown) => setError(reason instanceof Error ? reason.message : String(reason)));
};
return (
<span style={{ marginLeft: "auto", display: "inline-flex", alignItems: "center", gap: 6 }}>
<input className="input" type="date" aria-label={t("Target date")} value={value} onChange={(event) => change(event.target.value)} style={{ height: 26, padding: "0 6px", fontSize: 12 }} />
{error && <span className="form-error" role="alert">{error}</span>}
</span>
);
}
/** The calendar day of a time where the operator is, as a date input expects it. */
function localDate(time: Date): string {
const pad = (n: number) => String(n).padStart(2, "0");
return `${time.getFullYear()}-${pad(time.getMonth() + 1)}-${pad(time.getDate())}`;
}
@@ -0,0 +1,22 @@
import type { GoalRecord, TaskRecord } from "../../../../shared/domain";
/** How completion compares with the time elapsed toward the goal's target date. */
export type GoalPace = "ahead" | "on_track" | "behind" | "overdue";
/** Within this share of the plan either way, a goal is on track. */
const ON_TRACK_BAND = 0.1;
/** A goal's completion, and its pace only when it has a target date to measure against. */
export function goalProgress(goal: GoalRecord, tasks: TaskRecord[], now: Date): { percent: number; pace: GoalPace | null } {
const scoped = tasks.filter((task) => task.goalId === goal.id);
const done = scoped.filter((task) => task.status === "done").length;
const percent = scoped.length === 0 ? 0 : Math.round((done / scoped.length) * 100);
if (!goal.dueAt) return { percent, pace: null };
const start = new Date(goal.createdAt).getTime();
const due = new Date(goal.dueAt).getTime();
// Counted, not rounded: 200 of 201 done is still unfinished past the date.
if (now.getTime() > due && (scoped.length === 0 || done < scoped.length)) return { percent, pace: "overdue" };
const expected = due <= start ? 1 : Math.min(1, Math.max(0, (now.getTime() - start) / (due - start)));
const lead = percent / 100 - expected;
return { percent, pace: lead >= ON_TRACK_BAND ? "ahead" : lead >= -ON_TRACK_BAND ? "on_track" : "behind" };
}
@@ -34,6 +34,7 @@ export function GoalDialog({
title: text(form, "title"),
description: text(form, "description"),
ownerAgentId: optional(form, "ownerAgentId"),
dueAt: text(form, "dueDate") ? new Date(`${text(form, "dueDate")}T23:59:59`).toISOString() : null,
})).then((result) => { if (result) onClose(); });
};
@@ -41,6 +42,7 @@ export function GoalDialog({
<Dialog title={t("New goal")} open={open} submitLabel={t("Create")} onClose={onClose} onSubmit={submit}>
<Field label={t("Title")}><input className="input" name="title" required /></Field>
<Field label={t("Description")}><textarea className="input" name="description" required /></Field>
<Field label={t("Target date")}><input className="input" type="date" name="dueDate" /></Field>
<Field label={t("Owner agent")}>
<select className="input" name="ownerAgentId" defaultValue="">
<option value="">{t("Unassigned")}</option>
+9
View File
@@ -140,6 +140,15 @@ export interface CreateGoalInput {
title: string;
description: string;
ownerAgentId: string | null;
/** When the goal is planned to be reached; omitted means no target date. */
dueAt?: string | null;
}
export interface SetGoalDueDateInput {
orgId: string;
goalId: string;
/** ISO time the goal is planned to be reached, or null to clear it. */
dueAt: string | null;
}
export interface CreateProjectInput {
+2
View File
@@ -35,6 +35,7 @@ import type {
DeleteAutomationRuleInput,
DeleteDocumentInput,
DeleteTaskInput,
SetGoalDueDateInput,
SetTaskBlockedInput,
DeleteKnowledgeEntryInput,
ResumeGoalInput,
@@ -206,6 +207,7 @@ export interface DesktopApi {
/** What deleting a task would remove, keep and close. */
taskDeleteImpact(input: { orgId: string; taskId: string }): Promise<DesktopResult<TaskDeleteImpact>>;
/** Cancels a run that has not started yet. */
setGoalDueDate(input: SetGoalDueDateInput): Promise<DesktopResult<AppSnapshot>>;
cancelQueuedRun(input: { orgId: string; runId: string }): Promise<DesktopResult<AppSnapshot>>;
/** Runs older than a loaded one, for history beyond the snapshot's recent runs. */
runHistory(input: { orgId: string; beforeRunId: string | null } & ({ limit: number } | { from: RunCursor })): Promise<DesktopResult<RunHistoryPage>>;
+2
View File
@@ -172,6 +172,8 @@ export interface GoalRecord {
ownerAgentId: string | null;
/** Set when the goal's review loop stopped without a decision. */
stalledReason: string | null;
/** When the goal is planned to be reached; null when no target date was set. */
dueAt: string | null;
createdAt: string;
updatedAt: string;
}
+1
View File
@@ -87,6 +87,7 @@ export const IPC_CHANNELS = {
taskDeleteImpact: "agentcompany:task-delete-impact",
cancelQueuedRun: "agentcompany:cancel-queued-run",
runHistory: "agentcompany:run-history",
setGoalDueDate: "agentcompany:set-goal-due-date",
revokeConnector: "agentcompany:revoke-connector",
search: "agentcompany:search",
rendererEvent: "agentcompany:renderer-event",
+7
View File
@@ -72,6 +72,13 @@ test("the pinned CEO conversation is the Messages front door", async () => {
// The message itself says where the reply stands.
await expect(chatLog.locator(".chat-message", { hasText: "How are we tracking against the goal?" }).first()).toContainText("CEO will reply; queued.");
// A goal's pace is measured against its target date; without one, no pace is claimed.
await page.getByRole("button", { name: "Home" }).click();
await expect(page.getByText("no target date")).toBeVisible();
await page.getByLabel("Target date").fill("2099-12-31");
await expect(page.getByText("on track", { exact: true })).toBeVisible();
await expect(page.getByLabel("Target date")).toHaveValue("2099-12-31");
// From a task, its goal, run and back are one click each.
await page.getByRole("button", { name: "Work" }).click();
// The CEO's reply to the founder is a chat vehicle, not work: it stays off the board.
+1 -1
View File
@@ -35,7 +35,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, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101]);
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, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92, 93, 94, 95, 96, 97, 98, 99, 100, 101, 102]);
connection.close();
});
+29
View File
@@ -0,0 +1,29 @@
import { randomUUID } from "node:crypto";
import { tmpdir } from "node:os";
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";
describe("a goal's target date", () => {
test("is set at creation or later, cleared with null, and refused when it is not a date", () => {
const connection = new DatabaseConnection(join(tmpdir(), `ac-goal-due-${randomUUID()}.db`));
const organization = new OrganizationService(connection.db);
let orgId = "";
let goalId = "";
connection.transaction(() => {
orgId = organization.createOrganization({ organizationName: "Acme", employeeName: "Yang", mission: "m", goal: "g" });
goalId = organization.createGoal({ orgId, title: "Launch", description: "d", ownerAgentId: null, dueAt: "2026-12-31T23:59:59.000Z" });
});
const dueAt = () => (connection.db.prepare("select due_at from goals where id = ?").get(goalId) as { due_at: string | null }).due_at;
expect(dueAt()).toBe("2026-12-31T23:59:59.000Z");
organization.setGoalDueDate({ orgId, goalId, dueAt: null });
expect(dueAt()).toBeNull();
expect(() => organization.setGoalDueDate({ orgId, goalId, dueAt: "soon" })).toThrow('"soon" is not a date.');
expect(() => organization.setGoalDueDate({ orgId, goalId, dueAt: "2026-02-30T23:59:59.000Z" })).toThrow('"2026-02-30T23:59:59.000Z" is not a date.');
organization.setGoalDueDate({ orgId, goalId, dueAt: "2026-02-28T23:59:59Z" });
expect(dueAt()).toBe("2026-02-28T23:59:59.000Z");
expect(() => organization.setGoalDueDate({ orgId, goalId: "gol_missing", dueAt: null })).toThrow("Goal was not found.");
connection.close();
});
});
+24
View File
@@ -0,0 +1,24 @@
import { describe, expect, test } from "vitest";
import { goalProgress } from "../../src/renderer/src/pages/home/goal-progress";
import type { GoalRecord, TaskRecord } from "../../src/shared/domain";
const goal = (dueAt: string | null) => ({ id: "gol_1", createdAt: "2026-01-01T00:00:00.000Z", dueAt }) as GoalRecord;
const tasks = (done: number, total: number) => Array.from({ length: total }, (_, index) => ({ goalId: "gol_1", status: index < done ? "done" : "in_progress" })) as TaskRecord[];
describe("goal progress", () => {
test("without a target date it reports completion only, never a pace", () => {
expect(goalProgress(goal(null), tasks(1, 4), new Date("2026-01-10T00:00:00.000Z"))).toEqual({ percent: 25, pace: null });
});
test("with a target date the pace compares completion with the time elapsed toward it", () => {
const due = goal("2026-01-11T00:00:00.000Z");
const day = (n: number) => new Date(`2026-01-${String(n).padStart(2, "0")}T00:00:00.000Z`);
expect(goalProgress(due, tasks(1, 4), day(6))).toEqual({ percent: 25, pace: "behind" });
expect(goalProgress(due, tasks(2, 4), day(6))).toEqual({ percent: 50, pace: "on_track" });
expect(goalProgress(due, tasks(3, 4), day(6))).toEqual({ percent: 75, pace: "ahead" });
expect(goalProgress(due, tasks(3, 4), day(12))).toEqual({ percent: 75, pace: "overdue" });
expect(goalProgress(due, tasks(4, 4), day(12))).toEqual({ percent: 100, pace: "on_track" });
// Counted, not rounded: one unfinished task of many is still overdue.
expect(goalProgress(due, tasks(200, 201), day(12))).toEqual({ percent: 100, pace: "overdue" });
});
});