Files
fusion/packages/core/src/assigned-task-ranking.ts
gsxdsm dff864e098 feat: harden permanent-agent heartbeat instructions (#2081)
## Summary

Hardens permanent-agent operating law while keeping the
heartbeat/executor split:

- **Critical Rules** in task-scoped and no-task heartbeat system prompts
(survive custom `HEARTBEAT.md`)
- Stronger default procedures: disposition checklist, scoped-wake,
blocked dedup, progress note style
- **Wake Delta multi-assign inventory** (ranked, cap 8,
coordination-only framing) + `checkout_conflict` regression test
- Standing instructions six-section template for blank custom create /
empty detail insert
- Onboarding interview guidance to prefer structured `instructionsText`
- Playbooks, CONCEPTS, agents.md accuracy; remove stale agent
gap-analysis doc

Plan:
`docs/plans/2026-07-12-001-feat-permanent-agent-heartbeat-instructions-plan.md`

## Test plan

- [x] `pnpm --filter @fusion/core exec vitest run
src/__tests__/assigned-task-ranking.test.ts`
- [x] `pnpm --filter @fusion/engine exec vitest run
src/__tests__/agent-heartbeat-procedures.test.ts
src/__tests__/heartbeat-executor.test.ts -u`
- [x] `pnpm --filter @fusion/dashboard exec vitest run
app/components/__tests__/standing-instructions-template.test.ts`
- [ ] CI gate green on PR

## Residual Review Findings

None recorded at open (inline review; no residual sink).

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->

## Summary by CodeRabbit

* **New Features**
* Added ranked multi-assignment context to agent heartbeat wake-ups,
including task status, ownership, and lease details.
* Added standing-instructions templates for creating and editing
permanent agents.
* Improved onboarding guidance with a consistent six-section instruction
structure.
* Added clearer heartbeat handling for blocked tasks, no-task runs, and
checkout conflicts.

* **Documentation**
* Added permanent-agent heartbeat playbooks and expanded coordination
glossary entries.
  * Updated documentation indexes and heartbeat behavior guidance.

* **Tests**
* Added coverage for task ranking, instruction templates, wake-up
context, and conflict handling.

<!-- end of auto-generated comment: release notes by coderabbit.ai -->
2026-07-14 08:23:11 -07:00

197 lines
6.0 KiB
TypeScript

/*
FNXC:WakeDeltaMultiAssign 2026-07-13-12:15:
Permanent agents can own many tasks via assignedAgentId while agent.taskId is singular.
Heartbeat Wake Delta must surface a compact ranked inventory so coordinators can unblock/reassign without full-board thrash.
Membership is assignment-based; lease is annotation only; fully unactionable blocked stay count-only to avoid re-chase spam.
*/
/**
* Cap for titled multi-assign Wake Delta lines (plan U5: fixed 8, no setting).
*/
export const WAKE_DELTA_ASSIGNED_TASKS_CAP = 8;
export type AssignedTaskRankTier =
| "in_progress"
| "ready_todo"
| "partial_blocked"
| "other";
export interface AssignedTaskLike {
id: string;
column: string;
title?: string | null;
description?: string | null;
paused?: boolean | null;
dependencies?: string[] | null;
checkedOutBy?: string | null;
columnMovedAt?: string | null;
createdAt?: string | null;
deletedAt?: string | null;
}
export interface RankedAssignedTaskLine {
task: AssignedTaskLike;
tier: AssignedTaskRankTier;
labels: string[];
titleSnippet: string;
}
export interface RankAssignedTasksForWakeDeltaResult {
ranked: RankedAssignedTaskLine[];
totalOpen: number;
notActionableCount: number;
truncated: boolean;
}
function sortKey(task: AssignedTaskLike): string {
return task.columnMovedAt ?? task.createdAt ?? "";
}
function titleSnippet(task: AssignedTaskLike, max = 72): string {
const raw = (task.title?.trim() || task.description?.trim() || task.id).replace(/\s+/g, " ");
if (raw.length <= max) return raw;
return `${raw.slice(0, max - 1)}…`;
}
function isTerminalColumn(column: string): boolean {
return column === "done" || column === "archived";
}
/*
FNXC:WakeDeltaMultiAssign 2026-07-14-00:10:
Custom workflows use non-default column ids for ready/active work. Only treating
default `todo`/`in-progress` as titled hid assigned work as a bare count.
Map known default columns for rank quality; treat all other non-terminal open
columns (including custom workflow columns) as titled `other` so inventory stays
visible. Paused stays count-only to avoid re-chase noise.
*/
function tierForTask(task: AssignedTaskLike): AssignedTaskRankTier | "not_actionable" {
if (task.paused) return "not_actionable";
if (task.column === "in-progress") return "in_progress";
if (task.column === "todo") {
const deps = task.dependencies ?? [];
if (deps.length === 0) return "ready_todo";
// Coarse v1: non-empty deps ⇒ partial_blocked visibility (full dep hydrate deferred).
return "partial_blocked";
}
// Default triage/in-review and any project-specific open columns: keep titled
// at lowest rank so custom workflows do not hide assigned work as count-only.
return "other";
}
const TIER_ORDER: Record<AssignedTaskRankTier, number> = {
in_progress: 0,
ready_todo: 1,
partial_blocked: 2,
other: 3,
};
/**
* Rank open assigned tasks for Wake Delta multi-assign inventory.
* Excludes done/archived; titled lines only for actionable tiers; cap applied.
*/
export function rankAssignedTasksForWakeDelta(
tasks: AssignedTaskLike[],
options: {
agentId: string;
boundTaskId?: string | null;
cap?: number;
},
): RankAssignedTasksForWakeDeltaResult {
const cap = options.cap ?? WAKE_DELTA_ASSIGNED_TASKS_CAP;
const open = tasks.filter((t) => !t.deletedAt && !isTerminalColumn(t.column));
const titled: RankedAssignedTaskLine[] = [];
let notActionableCount = 0;
for (const task of open) {
const tierOrNa = tierForTask(task);
if (tierOrNa === "not_actionable") {
notActionableCount += 1;
continue;
}
const labels: string[] = [];
if (options.boundTaskId && task.id === options.boundTaskId) {
labels.push("bound");
}
if (task.checkedOutBy && task.checkedOutBy !== options.agentId) {
labels.push(`lease: held-by-other`);
}
titled.push({
task,
tier: tierOrNa,
labels,
titleSnippet: titleSnippet(task),
});
}
titled.sort((a, b) => {
const tierDiff = TIER_ORDER[a.tier] - TIER_ORDER[b.tier];
if (tierDiff !== 0) return tierDiff;
return sortKey(a.task).localeCompare(sortKey(b.task));
});
const truncated = titled.length > cap;
return {
ranked: titled.slice(0, cap),
totalOpen: open.length,
notActionableCount,
truncated,
};
}
/**
* Format ranked assigned tasks for Wake Delta markdown injection.
* Returns empty string when there is nothing useful to show.
*/
export function formatAssignedTasksWakeDeltaSection(
result: RankAssignedTasksForWakeDeltaResult,
options?: { showWhenSingleBoundOnly?: boolean; boundTaskId?: string | null },
): string {
const { ranked, totalOpen, notActionableCount, truncated } = result;
if (totalOpen === 0) return "";
// Prefer omit when only the bound task is titled and nothing else is open.
if (
ranked.length === 1 &&
options?.boundTaskId &&
ranked[0]?.task.id === options.boundTaskId &&
notActionableCount === 0 &&
!options.showWhenSingleBoundOnly
) {
return "";
}
if (ranked.length === 0 && notActionableCount === 0) return "";
const lines: string[] = [];
const actionableTotal = totalOpen - notActionableCount;
if (ranked.length > 0) {
const headerTotal = truncated
? `${ranked.length} of ${actionableTotal}`
: `${ranked.length}`;
lines.push(
`- your assigned tasks (coordination inventory — not an implement-from-heartbeat queue; ranked, ${headerTotal}):`,
);
ranked.forEach((row, index) => {
const labelSuffix = row.labels.length > 0 ? ` (${row.labels.join(", ")})` : "";
lines.push(
` ${index + 1}. ${row.task.id} [${row.tier}]${labelSuffix} ${row.titleSnippet}`,
);
});
if (truncated && actionableTotal > ranked.length) {
lines.push(
` (+${actionableTotal - ranked.length} more assigned open tasks; ranked list truncated — do not auto-retry checkout/claim)`,
);
}
}
if (notActionableCount > 0) {
lines.push(
`- also assigned not actionable now: ${notActionableCount} (paused)`,
);
}
return lines.join("\n");
}