chore: snapshot WIP across dashboard, engine, and core
Bundles staged work-in-progress modifications across multiple packages (routes, store, agent-instructions, self-healing, QuickEntryBox, etc.) plus the dashboard theme-data.css preload fix. Note: an unstaged 621-line deletion in .fusion/memory.md was deliberately NOT committed — it appears to be an accidental overwrite of architecture notes and is left in the working tree for review. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -581,3 +581,52 @@ const isValid = timingSafeEqual(Buffer.from(signature), Buffer.from(req.headers[
|
||||
- RoutineScheduler initialized after HeartbeatMonitor/TriggerScheduler
|
||||
- Graceful degradation if RoutineStore not available (FN-1519 types incomplete)
|
||||
- `getRoutineScheduler()` and `getRoutineRunner()` getters for testing access
|
||||
|
||||
## Dashboard Startup Perf — `listTasks` Hot Paths
|
||||
|
||||
The dashboard CLI (`pnpm dev dashboard`) was extremely slow on boards with
|
||||
~1200 tasks. Three independent code paths were each pulling the entire
|
||||
`tasks` table (with the full `log`/`comments`/`steps` JSON, ~67 MB) at
|
||||
startup and on every maintenance/sweep cycle.
|
||||
|
||||
**Bug 1 — `TaskStore.watch()` (`packages/core/src/store.ts`):** The 1-second
|
||||
poll loop in `checkForChanges()` filters on `updatedAt > lastPollTime`, but
|
||||
`lastPollTime` was left `null` after `watch()` populated the cache. The
|
||||
first poll cycle therefore ran an unfiltered `SELECT *` and emitted a
|
||||
`task:updated` SSE event for every cached task — ~60 MB of SSE traffic plus
|
||||
1199 React `setState` calls one second after dashboard startup. **Fix:** set
|
||||
`this.lastPollTime = new Date().toISOString()` at the end of `watch()` so
|
||||
the first poll only sees tasks that changed *after* the cache snapshot.
|
||||
|
||||
**Bug 2 — Auto-merge sweeps (`packages/cli/src/commands/dashboard.ts`):**
|
||||
The startup sweep, the two unpause handlers, and the periodic
|
||||
`scheduleMergeRetry()` (every 15s by default) all called
|
||||
`store.listTasks()` and then JS-filtered for in-review tasks. On a 1200-row
|
||||
board with mostly done/archived tasks that's a constant 67 MB allocation
|
||||
just to find 0–5 candidates. **Fix:** added `column?: Column` option to
|
||||
`listTasks` so callers can scope the SQL `WHERE` directly, and changed
|
||||
those four call sites to `listTasks({ column: "in-review" })`.
|
||||
|
||||
**Bug 3 — Engine maintenance (`packages/engine/src/self-healing.ts`):**
|
||||
`SelfHealingManager.archiveStaleDoneTasks()` runs every 15 min from
|
||||
`runMaintenance()`. It only needs `id`, `column`, and `columnMovedAt` to
|
||||
decide which done tasks are >48h old, but it called the full
|
||||
`listTasks()`. **Fix:** pass `{ slim: true }` — the slim row still includes
|
||||
those fields and excludes the heavy log/comments/steps payload.
|
||||
|
||||
**General contract going forward:** `listTasks()` is heavy by default. Hot
|
||||
paths must pass `{ slim: true }`, `{ column: ... }`, or
|
||||
`{ includeArchived: false }`. The board endpoint
|
||||
(`GET /api/tasks` in `packages/dashboard/src/routes.ts`) already uses
|
||||
slim+includeArchived; the archived column is loaded lazily on expand via a
|
||||
sticky `includeArchived` flag in `useTasks.ts`.
|
||||
|
||||
**Backlog cleanup:** `archiveStaleDoneTasks` walks tasks one at a time via
|
||||
`store.archiveTask(id)`, which is fine for the steady-state 5–20 tasks per
|
||||
cycle but would take minutes on the 866-task backlog after the
|
||||
auto-archive feature first lands. For one-off backlog cleanup, a direct
|
||||
SQL `UPDATE tasks SET column='archived', columnMovedAt=now,
|
||||
updatedAt=now WHERE column='done' AND columnMovedAt < cutoff` is safe and
|
||||
fast — subsequent watch() polls will pick up the changes and emit
|
||||
`task:moved` events. (Beware emitting hundreds of events in one cycle if a
|
||||
dashboard is connected.)
|
||||
|
||||
37
AGENTS.md
37
AGENTS.md
@@ -120,6 +120,43 @@ Nested data (arrays, objects) is stored as JSON text in SQLite columns:
|
||||
- Use `toJson()` for array columns, `toJsonNullable()` for nullable object columns
|
||||
- Use `fromJson<T>()` to parse JSON columns back to TypeScript types
|
||||
|
||||
### `listTasks()` performance contract
|
||||
|
||||
`store.listTasks()` returns full Task rows by default, including `log`,
|
||||
`comments`, `steps`, and `workflowStepResults`. On busy boards the `log`
|
||||
column alone can exceed 60 MB, so a naive call from a hot path will stall
|
||||
the dashboard. Always pass the narrowest option set the caller actually
|
||||
needs:
|
||||
|
||||
- `{ slim: true }` — board-style listing; drops heavy fields and returns empty
|
||||
arrays for them. Detail data must be fetched per-task via `getTask(id)`.
|
||||
- `{ column: "in-review" }` — column-scoped scans (e.g. the auto-merge sweep).
|
||||
Filtering happens in SQL, not in JS, so a 1200-row board collapses to
|
||||
whatever the column actually holds.
|
||||
- `{ includeArchived: false }` — exclude the archived column from the result
|
||||
set. The board view uses this; archived tasks are loaded lazily when the
|
||||
user expands that column.
|
||||
|
||||
The board path is wired this way already: `GET /api/tasks` uses
|
||||
`{ slim: true, includeArchived: <query> }`, and `archiveStaleDoneTasks` /
|
||||
the auto-merge sweeps in `dashboard.ts` use `slim`/`column`. New callers in
|
||||
hot paths (engine maintenance, schedulers, SSE side-effects) MUST pick one
|
||||
of these options — full `listTasks()` is reserved for tooling and tests.
|
||||
|
||||
### `TaskStore.watch()` polling
|
||||
|
||||
`watch()` populates an in-memory cache from `listTasks()` and starts a 1s
|
||||
poll loop (`checkForChanges`) that emits `task:created`/`updated`/`moved`/
|
||||
`deleted` events to SSE subscribers. The poll filters on `updatedAt /
|
||||
columnMovedAt > lastPollTime` — and `lastPollTime` MUST be initialized to
|
||||
"now" inside `watch()` itself. If it is left null, the first poll cycle
|
||||
runs an unfiltered `SELECT *` and emits `task:updated` for every cached
|
||||
task, causing tens of MB of SSE traffic and a frontend setState storm at
|
||||
dashboard startup. Direct `UPDATE`s against `tasks` (e.g. bulk archive
|
||||
sweeps) will also be observed by the next poll cycle and re-emitted as
|
||||
events, which is fine in small numbers but should be batched if a sweep
|
||||
touches hundreds of rows at once.
|
||||
|
||||
## Testing
|
||||
|
||||
```bash
|
||||
|
||||
@@ -876,7 +876,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
// ── Startup sweep: enqueue any tasks already in "in-review" ───────
|
||||
if (settings.autoMerge) {
|
||||
const existing = await store.listTasks();
|
||||
const existing = await store.listTasks({ column: "in-review" });
|
||||
const inReview = existing.filter((t) => canAutoMergeTask(t as any));
|
||||
if (inReview.length > 0) {
|
||||
console.log(
|
||||
@@ -911,7 +911,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
if (s.autoMerge) {
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ column: "in-review" });
|
||||
for (const t of tasks) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
@@ -935,7 +935,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
|
||||
if (s.autoMerge) {
|
||||
try {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ column: "in-review" });
|
||||
for (const t of tasks) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
@@ -999,7 +999,7 @@ export async function runDashboard(port: number, opts: { paused?: boolean; dev?:
|
||||
// Refresh the cached limit so the semaphore picks up live changes
|
||||
cachedMaxConcurrent = s.maxConcurrent;
|
||||
if (!s.globalPause && !s.enginePaused && s.autoMerge) {
|
||||
const tasks = await store.listTasks();
|
||||
const tasks = await store.listTasks({ column: "in-review" });
|
||||
for (const t of tasks) {
|
||||
if (canAutoMergeTask(t as any)) {
|
||||
enqueueMerge(t.id);
|
||||
|
||||
@@ -1323,7 +1323,7 @@ describe("TaskStore", () => {
|
||||
expect(paged[0].id).toBe("FN-002");
|
||||
});
|
||||
|
||||
it("slim mode returns metadata but drops heavy fields (log/comments/steps)", async () => {
|
||||
it("slim mode drops the agent log but keeps board-visible fields (steps/comments)", async () => {
|
||||
const task = await store.createTask({ description: "Slim test" });
|
||||
await store.logEntry(task.id, "heavy log entry that should not appear in slim list");
|
||||
|
||||
@@ -1333,13 +1333,22 @@ describe("TaskStore", () => {
|
||||
const full = fullList.find((t) => t.id === task.id)!;
|
||||
const slim = slimList.find((t) => t.id === task.id)!;
|
||||
|
||||
// Sanity: the full row really has the log we wrote.
|
||||
expect(full.log.length).toBeGreaterThan(0);
|
||||
|
||||
// Slim must drop the heavy log payload (the only field worth slimming).
|
||||
expect(slim.id).toBe(task.id);
|
||||
expect(slim.description).toBe("Slim test");
|
||||
expect(slim.column).toBe(full.column);
|
||||
expect(slim.log).toEqual([]);
|
||||
expect(slim.steps).toEqual([]);
|
||||
expect(slim.comments).toBeUndefined();
|
||||
|
||||
// Slim must STILL include the small JSON columns the board UI reads:
|
||||
// step progress, comment counts, workflow status, steering badges.
|
||||
// (Dropping them silently broke TaskCard progress bars and the comments tab.)
|
||||
expect(slim.steps).toEqual(full.steps);
|
||||
expect(slim.comments).toEqual(full.comments);
|
||||
expect(slim.workflowStepResults).toEqual(full.workflowStepResults);
|
||||
expect(slim.steeringComments).toEqual(full.steeringComments);
|
||||
});
|
||||
|
||||
it("includeArchived=false excludes archived tasks; default includes them", async () => {
|
||||
|
||||
@@ -1316,10 +1316,22 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
* from each row to make list responses cheap for board-style consumers. Detail fields default
|
||||
* to empty arrays in the returned Task objects; use `getTask(id)` to load full data. */
|
||||
slim?: boolean;
|
||||
/** Restrict to a single column (e.g. 'in-review' for the auto-merge sweep). */
|
||||
column?: Column;
|
||||
}): Promise<Task[]> {
|
||||
const includeArchived = options?.includeArchived ?? true;
|
||||
const slim = options?.slim ?? false;
|
||||
const columnFilter = options?.column;
|
||||
|
||||
// Slim mode drops ONLY the agent log column. On busy boards `log` accounts
|
||||
// for ~99% of the row payload (60+ MB across 1200 tasks); every other JSON
|
||||
// column combined is under 500 KB and is needed by the board UI:
|
||||
// - `steps` → step progress badge on TaskCard
|
||||
// - `comments` → comment count badge on TaskCard
|
||||
// - `workflowStepResults` → workflow status indicators
|
||||
// - `steeringComments` → steering badge
|
||||
// Use `getTask(id)` to load the full row (including `log`) for the
|
||||
// TaskDetailModal's Activity tab and Agent Log subview.
|
||||
const slimColumns = `
|
||||
id, title, description, "column", status, size, reviewLevel, currentStep,
|
||||
worktree, blockedBy, paused, baseBranch, branch, baseCommitSha,
|
||||
@@ -1329,17 +1341,25 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
mergeRetries, stuckKillCount, recoveryRetryCount, nextRecoveryAt,
|
||||
error, summary, thinkingLevel,
|
||||
createdAt, updatedAt, columnMovedAt,
|
||||
dependencies,
|
||||
dependencies, steps, comments, workflowStepResults, steeringComments,
|
||||
attachments, prInfo, issueInfo, mergeDetails,
|
||||
breakIntoSubtasks, enabledWorkflowSteps, modifiedFiles,
|
||||
missionId, sliceId, assignedAgentId, assigneeUserId,
|
||||
checkedOutBy, checkedOutAt
|
||||
`;
|
||||
const selectClause = slim ? slimColumns : '*';
|
||||
const whereClause = includeArchived ? '' : ` WHERE "column" != 'archived'`;
|
||||
const whereParts: string[] = [];
|
||||
const params: string[] = [];
|
||||
if (columnFilter) {
|
||||
whereParts.push(`"column" = ?`);
|
||||
params.push(columnFilter);
|
||||
} else if (!includeArchived) {
|
||||
whereParts.push(`"column" != 'archived'`);
|
||||
}
|
||||
const whereClause = whereParts.length > 0 ? ` WHERE ${whereParts.join(" AND ")}` : "";
|
||||
const sql = `SELECT ${selectClause} FROM tasks${whereClause} ORDER BY createdAt ASC`;
|
||||
|
||||
const rows = this.db.prepare(sql).all();
|
||||
const rows = this.db.prepare(sql).all(...params);
|
||||
const tasks = (rows as any[]).map((row) => this.rowToTask(row));
|
||||
|
||||
// Sort by createdAt, then by numeric ID suffix for tie-breaking
|
||||
@@ -2699,6 +2719,11 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
|
||||
|
||||
// Store current lastModified
|
||||
this.lastKnownModified = this.db.getLastModified();
|
||||
// Initialize lastPollTime so the first checkForChanges() cycle filters by
|
||||
// "modified since now" instead of doing a full SELECT * + emitting an
|
||||
// update event for every cached task. Without this, dashboard startup
|
||||
// re-loaded the entire tasks table 1s after watch() began.
|
||||
this.lastPollTime = new Date().toISOString();
|
||||
|
||||
// Use a sentinel watcher object so existing code that checks `this.watcher` still works
|
||||
try {
|
||||
|
||||
@@ -1016,7 +1016,7 @@ export function QuickEntryBox({ onCreate, addToast, tasks = [], availableModels,
|
||||
onBlur={handleBlur}
|
||||
disabled={isSubmitting || isDisabled}
|
||||
data-testid="quick-entry-input"
|
||||
rows={1}
|
||||
rows={2}
|
||||
aria-controls="quick-entry-controls"
|
||||
aria-expanded={isDisclosureExpanded}
|
||||
/>
|
||||
|
||||
@@ -1083,9 +1083,9 @@ export function TaskDetailModal({
|
||||
) : (
|
||||
<div className="detail-activity">
|
||||
<h4>Activity</h4>
|
||||
{task.log && task.log.length > 0 ? (
|
||||
{workingTask.log && workingTask.log.length > 0 ? (
|
||||
<div className="detail-activity-list">
|
||||
{[...task.log].reverse().map((entry, i) => (
|
||||
{[...workingTask.log].reverse().map((entry, i) => (
|
||||
<div key={i} className="detail-log-entry">
|
||||
<div className="detail-log-header">
|
||||
<span className="detail-log-timestamp">
|
||||
@@ -1180,10 +1180,10 @@ export function TaskDetailModal({
|
||||
</div>
|
||||
<div className="detail-section detail-step-progress">
|
||||
<h4>Progress</h4>
|
||||
{task.steps && task.steps.length > 0 ? (
|
||||
{workingTask.steps && workingTask.steps.length > 0 ? (
|
||||
<div className="step-progress-wrapper">
|
||||
<div className="step-progress-bar">
|
||||
{task.steps.map((step, index) => (
|
||||
{workingTask.steps.map((step, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className={`step-progress-segment step-progress-segment--${step.status}`}
|
||||
@@ -1193,7 +1193,7 @@ export function TaskDetailModal({
|
||||
))}
|
||||
</div>
|
||||
<span className="step-progress-label">
|
||||
{task.steps.filter(s => s.status === "done").length}/{task.steps.length} steps
|
||||
{workingTask.steps.filter(s => s.status === "done").length}/{workingTask.steps.length} steps
|
||||
</span>
|
||||
</div>
|
||||
) : (
|
||||
|
||||
@@ -234,6 +234,13 @@ describe("QuickEntryBox", () => {
|
||||
expect((textarea as HTMLTextAreaElement).placeholder).toBe("Add a task...");
|
||||
});
|
||||
|
||||
it("renders textarea with baseline height of 2 rows (FN-1580)", () => {
|
||||
renderQuickEntryBox({});
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
expect(textarea).toBeTruthy();
|
||||
expect((textarea as HTMLTextAreaElement).rows).toBe(2);
|
||||
});
|
||||
|
||||
it("does NOT expand on focus when autoExpand is false", () => {
|
||||
renderQuickEntryBox({ autoExpand: false });
|
||||
const textarea = screen.getByTestId("quick-entry-input");
|
||||
|
||||
@@ -10286,6 +10286,7 @@ describe("Agent create/update routes", () => {
|
||||
permissions: { read: true },
|
||||
instructionsPath: "docs/reviewer.md",
|
||||
instructionsText: "Check test quality.",
|
||||
soul: "Analytical and thorough.",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
@@ -10302,6 +10303,7 @@ describe("Agent create/update routes", () => {
|
||||
permissions: { read: true },
|
||||
instructionsPath: "docs/reviewer.md",
|
||||
instructionsText: "Check test quality.",
|
||||
soul: "Analytical and thorough.",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -10324,6 +10326,7 @@ describe("Agent create/update routes", () => {
|
||||
totalOutputTokens: 21,
|
||||
instructionsPath: "agents/infra.md",
|
||||
instructionsText: "Focus on reliability.",
|
||||
soul: "Pragmatic and efficient.",
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
@@ -10344,9 +10347,44 @@ describe("Agent create/update routes", () => {
|
||||
totalOutputTokens: 21,
|
||||
instructionsPath: "agents/infra.md",
|
||||
instructionsText: "Focus on reliability.",
|
||||
soul: "Pragmatic and efficient.",
|
||||
});
|
||||
});
|
||||
|
||||
it("POST /api/agents returns 400 when soul exceeds 10,000 characters", async () => {
|
||||
const longSoul = "x".repeat(10001);
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"POST",
|
||||
"/api/agents",
|
||||
JSON.stringify({
|
||||
name: "Soul Test Agent",
|
||||
role: "executor",
|
||||
soul: longSoul,
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("soul must be at most 10,000 characters");
|
||||
});
|
||||
|
||||
it("PATCH /api/agents/:id returns 400 when soul exceeds 10,000 characters", async () => {
|
||||
const longSoul = "x".repeat(10001);
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
"PATCH",
|
||||
`/api/agents/${agentId}`,
|
||||
JSON.stringify({
|
||||
soul: longSoul,
|
||||
}),
|
||||
{ "Content-Type": "application/json" },
|
||||
);
|
||||
|
||||
expect(res.status).toBe(400);
|
||||
expect(res.body.error).toContain("soul must be at most 10,000 characters");
|
||||
});
|
||||
|
||||
it("POST /api/agents/:id/state returns 400 for invalid state transitions", async () => {
|
||||
const res = await REQUEST(
|
||||
buildAgentApp(),
|
||||
|
||||
@@ -8998,6 +8998,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
permissions,
|
||||
instructionsPath,
|
||||
instructionsText,
|
||||
soul,
|
||||
} = req.body ?? {};
|
||||
|
||||
if (!name || typeof name !== "string") {
|
||||
@@ -9027,6 +9028,12 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
if (!validateAgentInstructionsPayload(res, instructionsPath, instructionsText)) {
|
||||
return;
|
||||
}
|
||||
if (soul !== undefined && soul !== null && typeof soul !== "string") {
|
||||
throw badRequest("soul must be a string");
|
||||
}
|
||||
if (typeof soul === "string" && soul.length > 10000) {
|
||||
throw badRequest("soul must be at most 10,000 characters");
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
@@ -9044,6 +9051,7 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
permissions,
|
||||
instructionsPath: instructionsPath ?? undefined,
|
||||
instructionsText: instructionsText ?? undefined,
|
||||
soul: soul ?? undefined,
|
||||
});
|
||||
res.status(201).json(agent);
|
||||
} catch (err: any) {
|
||||
@@ -9473,6 +9481,16 @@ export function createApiRoutes(store: TaskStore, options?: ServerOptions): Rout
|
||||
updates.instructionsText = body.instructionsText ?? undefined;
|
||||
}
|
||||
|
||||
if ("soul" in body) {
|
||||
if (body.soul !== null && typeof body.soul !== "string") {
|
||||
throw badRequest("soul must be a string");
|
||||
}
|
||||
if (typeof body.soul === "string" && body.soul.length > 10000) {
|
||||
throw badRequest("soul must be at most 10,000 characters");
|
||||
}
|
||||
updates.soul = body.soul ?? undefined;
|
||||
}
|
||||
|
||||
const scopedStore = await getScopedStore(req);
|
||||
const { AgentStore } = await import("@fusion/core");
|
||||
const agentStore = new AgentStore({ rootDir: scopedStore.getFusionDir() });
|
||||
|
||||
@@ -78,6 +78,12 @@ describe("resolveAgentInstructions", () => {
|
||||
expect(result).toBe("");
|
||||
});
|
||||
|
||||
it("returns soul-only agent with no instructions", async () => {
|
||||
const agent = makeAgent({ soul: "Be thorough and analytical." });
|
||||
const result = await resolveAgentInstructions(agent, testDir);
|
||||
expect(result).toBe("## Soul\n\nBe thorough and analytical.");
|
||||
});
|
||||
|
||||
it("returns instructionsText when set", async () => {
|
||||
const agent = makeAgent({ instructionsText: "Always write tests." });
|
||||
const result = await resolveAgentInstructions(agent, testDir);
|
||||
@@ -204,6 +210,36 @@ describe("resolveAgentInstructions", () => {
|
||||
|
||||
expect(result.length).toBe(50000);
|
||||
});
|
||||
|
||||
it("truncates oversized soul", async () => {
|
||||
const oversized = "s".repeat(10010);
|
||||
const agent = makeAgent({ soul: oversized });
|
||||
|
||||
const result = await resolveAgentInstructions(agent, testDir);
|
||||
expect(result.length).toBe(10000 + "## Soul\n\n".length);
|
||||
});
|
||||
|
||||
it("places soul section after instructionsText and before performance feedback", async () => {
|
||||
const filePath = join(testDir, "file-instructions.md");
|
||||
await writeFile(filePath, "File-based instructions here.");
|
||||
|
||||
const agent = makeAgent({
|
||||
instructionsText: "Inline instructions.",
|
||||
instructionsPath: "file-instructions.md",
|
||||
soul: "Be methodical and detailed.",
|
||||
});
|
||||
|
||||
const result = await resolveAgentInstructions(agent, testDir);
|
||||
|
||||
// Verify section order
|
||||
const soulIndex = result.indexOf("## Soul");
|
||||
const instructionsTextIndex = result.indexOf("Inline instructions.");
|
||||
const instructionsFileIndex = result.indexOf("File-based instructions here.");
|
||||
|
||||
expect(instructionsTextIndex).toBeLessThan(soulIndex);
|
||||
expect(instructionsFileIndex).toBeLessThan(soulIndex);
|
||||
expect(soulIndex).toBeLessThan(result.indexOf("## Soul") + 10); // Soul section is present
|
||||
});
|
||||
});
|
||||
|
||||
describe("resolveAgentInstructions with rating summary", () => {
|
||||
@@ -243,6 +279,29 @@ describe("resolveAgentInstructions with rating summary", () => {
|
||||
expect(result).toContain(' - "Could communicate blockers sooner" (score: 4.0)');
|
||||
});
|
||||
|
||||
it("places soul section before performance feedback and after instructions", async () => {
|
||||
const agent = makeAgent({
|
||||
instructionsText: "Implement the feature.",
|
||||
soul: "Be pragmatic and efficient.",
|
||||
});
|
||||
const summary = makeRatingSummary({
|
||||
totalRatings: 3,
|
||||
trend: "stable",
|
||||
});
|
||||
|
||||
const result = await resolveAgentInstructions(agent, testDir, summary);
|
||||
|
||||
// Verify section order: instructionsText → soul → Performance Feedback
|
||||
const instructionsIndex = result.indexOf("Implement the feature.");
|
||||
const soulIndex = result.indexOf("## Soul");
|
||||
const feedbackIndex = result.indexOf("## Performance Feedback");
|
||||
|
||||
expect(instructionsIndex).toBeLessThan(soulIndex);
|
||||
expect(soulIndex).toBeLessThan(feedbackIndex);
|
||||
expect(result).toContain("## Soul");
|
||||
expect(result).toContain("## Performance Feedback");
|
||||
});
|
||||
|
||||
it("shows the correct trend indicator for all trend states", async () => {
|
||||
const agent = makeAgent({ instructionsText: "Base instructions" });
|
||||
const trends: Array<[AgentRatingSummary["trend"], string]> = [
|
||||
|
||||
@@ -4,6 +4,7 @@ import type { Agent, AgentRatingSummary, AgentStore } from "@fusion/core";
|
||||
|
||||
const MAX_INSTRUCTIONS_PATH_LENGTH = 500;
|
||||
const MAX_INSTRUCTIONS_TEXT_LENGTH = 50_000;
|
||||
const MAX_SOUL_LENGTH = 10_000;
|
||||
|
||||
function trimAndClamp(value: string, maxLength: number, label: string, agentId: string): string {
|
||||
const trimmed = value.trim();
|
||||
@@ -77,6 +78,14 @@ function getTrendLabel(trend: AgentRatingSummary["trend"]): string {
|
||||
}
|
||||
}
|
||||
|
||||
function formatSoulSection(soul: string, agentId: string): string {
|
||||
const trimmed = trimAndClamp(soul, MAX_SOUL_LENGTH, "soul", agentId);
|
||||
if (!trimmed) {
|
||||
return "";
|
||||
}
|
||||
return `## Soul\n\n${trimmed}`;
|
||||
}
|
||||
|
||||
function formatPerformanceFeedbackSection(ratingSummary: AgentRatingSummary): string {
|
||||
const lines: string[] = [
|
||||
"## Performance Feedback",
|
||||
@@ -170,6 +179,14 @@ export async function resolveAgentInstructions(
|
||||
}
|
||||
}
|
||||
|
||||
// Soul/personality section (after instructions, before performance feedback)
|
||||
if (agent.soul?.trim()) {
|
||||
const soulSection = formatSoulSection(agent.soul, agent.id);
|
||||
if (soulSection) {
|
||||
parts.push(soulSection);
|
||||
}
|
||||
}
|
||||
|
||||
if (ratingSummary && ratingSummary.totalRatings > 0) {
|
||||
parts.push(formatPerformanceFeedbackSection(ratingSummary));
|
||||
}
|
||||
|
||||
@@ -365,7 +365,10 @@ export class SelfHealingManager {
|
||||
|
||||
async archiveStaleDoneTasks(): Promise<number> {
|
||||
try {
|
||||
const tasks = await this.store.listTasks();
|
||||
// Slim listing — we only need id/column/columnMovedAt/updatedAt to decide
|
||||
// staleness. Pulling full task payloads (logs, comments, steps) here used
|
||||
// to drag in tens of MB on busy boards and stalled the maintenance loop.
|
||||
const tasks = await this.store.listTasks({ slim: true });
|
||||
const cutoff = Date.now() - SelfHealingManager.AUTO_ARCHIVE_AFTER_MS;
|
||||
|
||||
const stale = tasks.filter((t) => {
|
||||
|
||||
Reference in New Issue
Block a user