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

@@ -44,6 +44,7 @@ import type {
Task,
} from "./types.js";
import { AGENT_VALID_TRANSITIONS, agentToConfigSnapshot, diffConfigSnapshots, CheckoutConflictError } from "./types.js";
import type { RunMutationContext } from "./types.js";
import type { TaskStore } from "./store.js";
import { computeAccessState } from "./agent-permissions.js";
import { Database } from "./db.js";
@@ -795,7 +796,7 @@ export class AgentStore extends EventEmitter {
* @param taskId - The task ID to assign, or undefined to unassign
* @returns The updated agent
*/
async assignTask(agentId: string, taskId: string | undefined): Promise<Agent> {
async assignTask(agentId: string, taskId: string | undefined, runContext?: RunMutationContext): Promise<Agent> {
return this.withLock(agentId, async () => {
const agent = await this.getAgent(agentId);
if (!agent) {
@@ -816,6 +817,11 @@ export class AgentStore extends EventEmitter {
this.emit("agent:assigned", updated, taskId);
}
// Log the assignment to the task when a non-empty taskId is provided
if (taskId && this.taskStore) {
await this.taskStore.logEntry(taskId, `Task assigned to agent ${agentId}`, undefined, runContext);
}
return updated;
});
}
@@ -824,7 +830,7 @@ export class AgentStore extends EventEmitter {
* Acquire a checkout lease for a task.
* Throws CheckoutConflictError when another agent already holds the lease.
*/
async checkoutTask(agentId: string, taskId: string): Promise<Task> {
async checkoutTask(agentId: string, taskId: string, runContext?: RunMutationContext): Promise<Task> {
if (!this.taskStore) {
throw new Error("TaskStore not configured for checkout operations");
}
@@ -848,14 +854,14 @@ export class AgentStore extends EventEmitter {
}
const updated = await this.taskStore.updateTask(taskId, { checkedOutBy: agentId });
await this.taskStore.logEntry(taskId, `Checked out by agent ${agentId}`);
await this.taskStore.logEntry(taskId, `Checked out by agent ${agentId}`, undefined, runContext);
return updated;
}
/**
* Release a checkout lease for a task.
*/
async releaseTask(agentId: string, taskId: string): Promise<Task> {
async releaseTask(agentId: string, taskId: string, runContext?: RunMutationContext): Promise<Task> {
if (!this.taskStore) {
throw new Error("TaskStore not configured for checkout operations");
}
@@ -874,20 +880,20 @@ export class AgentStore extends EventEmitter {
}
const updated = await this.taskStore.updateTask(taskId, { checkedOutBy: null });
await this.taskStore.logEntry(taskId, `Released by agent ${agentId}`);
await this.taskStore.logEntry(taskId, `Released by agent ${agentId}`, undefined, runContext);
return updated;
}
/**
* Force release a task checkout lease regardless of holder.
*/
async forceReleaseTask(taskId: string): Promise<Task> {
async forceReleaseTask(taskId: string, runContext?: RunMutationContext): Promise<Task> {
if (!this.taskStore) {
throw new Error("TaskStore not configured for checkout operations");
}
const updated = await this.taskStore.updateTask(taskId, { checkedOutBy: null });
await this.taskStore.logEntry(taskId, "Checkout force-released");
await this.taskStore.logEntry(taskId, "Checkout force-released", undefined, runContext);
return updated;
}