feat(FN-1257): add runContext audit trail for task mutations

- Add RunMutationContext type to track which agent run caused a mutation
- Thread runContext through TaskStore.logEntry, addComment, addSteeringComment, and pauseTask
- Propagate runContext from HeartbeatMonitor.executeHeartbeat to task store operations
- Propagate runContext from TaskExecutor.execute to task store operations
- Add GET /api/agents/:id/runs/:runId/mutations endpoint to query mutations by runId
- Add createTaskLogToolWithContext for heartbeat tools with run context support
- Add comprehensive tests for RunMutationContext across store and heartbeat modules
- Update memory.md with RunMutationContext usage convention
This commit is contained in:
gsxdsm
2026-04-09 13:59:43 -07:00
parent b5b37c4fa6
commit 9f8fd5e520
12 changed files with 674 additions and 167 deletions

View File

@@ -4,7 +4,7 @@ import { randomUUID } from "node:crypto";
import { appendFile, mkdir, readFile, writeFile, rename, unlink } from "node:fs/promises";
import { join } from "node:path";
import { existsSync, watch, type FSWatcher } from "node:fs";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, InboxTask } from "./types.js";
import type { Task, TaskDetail, TaskCreateInput, TaskAttachment, AgentLogEntry, BoardConfig, Column, MergeResult, Settings, GlobalSettings, ProjectSettings, ActivityLogEntry, ActivityEventType, TaskDocument, TaskDocumentRevision, TaskDocumentCreateInput, InboxTask, TaskLogEntry, RunMutationContext } from "./types.js";
import { VALID_TRANSITIONS, DEFAULT_SETTINGS, GLOBAL_SETTINGS_KEYS, WORKFLOW_STEP_TEMPLATES, validateDocumentKey } from "./types.js";
import { GlobalSettingsStore } from "./global-settings.js";
import { Database, toJson, toJsonNullable, fromJson } from "./db.js";
@@ -1394,6 +1394,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
async updateTask(
id: string,
updates: { title?: string; description?: string; prompt?: string; worktree?: string | null; status?: string | null; dependencies?: string[]; blockedBy?: string | null; assignedAgentId?: string | null; checkedOutBy?: string | null; checkedOutAt?: string | null; paused?: boolean; baseBranch?: string | null; branch?: string | null; baseCommitSha?: string | null; size?: "S" | "M" | "L"; reviewLevel?: number; mergeRetries?: number; stuckKillCount?: number | null; recoveryRetryCount?: number | null; nextRecoveryAt?: string | null; enabledWorkflowSteps?: string[]; modelProvider?: string | null; modelId?: string | null; validatorModelProvider?: string | null; validatorModelId?: string | null; planningModelProvider?: string | null; planningModelId?: string | null; thinkingLevel?: string | null; error?: string | null; summary?: string | null; sessionFile?: string | null; workflowStepResults?: import("./types.js").WorkflowStepResult[] | null; mergeDetails?: import("./types.js").MergeDetails | null; modifiedFiles?: string[] | null; missionId?: string | null; sliceId?: string | null },
runContext?: RunMutationContext,
): Promise<Task> {
return this.withTaskLock(id, async () => {
// Validate that task doesn't depend on itself
@@ -1427,10 +1428,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.column = "triage";
task.status = undefined;
task.columnMovedAt = new Date().toISOString();
task.log.push({
const depLogEntry: TaskLogEntry = {
timestamp: new Date().toISOString(),
action: "Moved to triage for re-specification — new dependency added",
});
};
if (runContext) {
depLogEntry.runContext = runContext;
}
task.log.push(depLogEntry);
movedToTriage = true;
}
}
@@ -1613,7 +1618,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* Pause or unpause a task. Paused tasks are excluded from all automated
* agent and scheduler interaction. Logs the action and emits `task:updated`.
*/
async pauseTask(id: string, paused: boolean): Promise<Task> {
async pauseTask(id: string, paused: boolean, runContext?: RunMutationContext): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
@@ -1631,10 +1636,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
const now = new Date().toISOString();
task.updatedAt = now;
task.log.push({
const logEntry: TaskLogEntry = {
timestamp: now,
action: paused ? "Task paused" : "Task unpaused",
});
};
if (runContext) {
logEntry.runContext = runContext;
}
task.log.push(logEntry);
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
@@ -1704,7 +1713,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
/**
* Add a log entry to a task.
*/
async logEntry(id: string, action: string, outcome?: string): Promise<Task> {
async logEntry(id: string, action: string, outcome?: string, runContext?: RunMutationContext): Promise<Task> {
return this.withTaskLock(id, async () => {
const dir = this.taskDir(id);
const task = await this.readTaskJson(dir);
@@ -1714,11 +1723,15 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
task.log = [];
}
task.log.push({
const entry: TaskLogEntry = {
timestamp: new Date().toISOString(),
action,
outcome,
});
};
if (runContext) {
entry.runContext = runContext;
}
task.log.push(entry);
task.updatedAt = new Date().toISOString();
await this.atomicWriteTaskJson(dir, task);
@@ -1729,6 +1742,25 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
});
}
/**
* Get all task log entries correlated with a specific run ID.
* Scans all tasks' logs for entries whose runContext.runId matches.
*/
async getMutationsForRun(runId: string): Promise<TaskLogEntry[]> {
const allTasks = await this.listTasks();
const mutations: TaskLogEntry[] = [];
for (const task of allTasks) {
if (!task.log) continue;
for (const entry of task.log) {
if (entry.runContext?.runId === runId) {
mutations.push(entry);
}
}
}
// Sort by timestamp ascending
return mutations.sort((a, b) => a.timestamp.localeCompare(b.timestamp));
}
/**
* Sync steps from PROMPT.md into task.json (called when steps are empty).
*/
@@ -2570,9 +2602,9 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
* `steeringComments` (for executor real-time injection).
* Unlike regular comments, steering comments never trigger auto-refinement.
*/
async addSteeringComment(id: string, text: string, author: "user" | "agent" = "user"): Promise<Task> {
async addSteeringComment(id: string, text: string, author: "user" | "agent" = "user", runContext?: RunMutationContext): Promise<Task> {
// Write to unified comments (skip refinement — steering is for agent injection, not follow-up tasks)
const task = await this.addComment(id, text, author, { skipRefinement: true });
const task = await this.addComment(id, text, author, { skipRefinement: true }, runContext);
// Also write to steeringComments so the executor's real-time injection listener can detect new entries
const updated = await this.withTaskLock(id, async () => {
@@ -2669,6 +2701,7 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
text: string,
author: string = "user",
options?: { skipRefinement?: boolean },
runContext?: RunMutationContext,
): Promise<Task> {
// Phase 1: Add comment under lock
const task = await this.withTaskLock(id, async () => {
@@ -2696,10 +2729,14 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
}
task.comments.push(comment);
task.updatedAt = new Date().toISOString();
task.log.push({
const logEntry: TaskLogEntry = {
timestamp: task.updatedAt,
action: `Comment added by ${author}`,
});
};
if (runContext) {
logEntry.runContext = runContext;
}
task.log.push(logEntry);
await this.atomicWriteTaskJson(dir, task);
if (this.isWatching) this.taskCache.set(id, { ...task });
@@ -2739,6 +2776,8 @@ export class TaskStore extends EventEmitter<TaskStoreEvents> {
await this.logEntry(
id,
`User comment invalidated spec approval — task needs re-specification`,
undefined,
runContext,
);
} catch {
// Best-effort: don't fail the comment if the status update fails