mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-15 18:30:39 +09:00
[codex] Add runtime lifecycle recovery and live issue visibility (#4419)
This commit is contained in:
parent
9a8d219949
commit
5a0c1979cf
121 changed files with 9625 additions and 2044 deletions
|
|
@ -14,7 +14,7 @@
|
|||
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import os from "node:os";
|
||||
import { resolvePaperclipHomeDir } from "../home-paths.js";
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Types
|
||||
|
|
@ -43,25 +43,30 @@ interface AdapterSettings {
|
|||
// Paths
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PAPERCLIP_DIR = path.join(os.homedir(), ".paperclip");
|
||||
const ADAPTER_PLUGINS_DIR = path.join(PAPERCLIP_DIR, "adapter-plugins");
|
||||
const ADAPTER_PLUGINS_STORE_PATH = path.join(PAPERCLIP_DIR, "adapter-plugins.json");
|
||||
const ADAPTER_SETTINGS_PATH = path.join(PAPERCLIP_DIR, "adapter-settings.json");
|
||||
function adapterPluginPaths() {
|
||||
const paperclipDir = resolvePaperclipHomeDir();
|
||||
return {
|
||||
adapterPluginsDir: path.join(paperclipDir, "adapter-plugins"),
|
||||
adapterPluginsStorePath: path.join(paperclipDir, "adapter-plugins.json"),
|
||||
adapterSettingsPath: path.join(paperclipDir, "adapter-settings.json"),
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// In-memory caches (invalidated on write)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
let storeCache: AdapterPluginRecord[] | null = null;
|
||||
let settingsCache: AdapterSettings | null = null;
|
||||
let storeCache: { path: string; records: AdapterPluginRecord[] } | null = null;
|
||||
let settingsCache: { path: string; settings: AdapterSettings } | null = null;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Store functions
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ensureDirs(): void {
|
||||
fs.mkdirSync(ADAPTER_PLUGINS_DIR, { recursive: true });
|
||||
const pkgJsonPath = path.join(ADAPTER_PLUGINS_DIR, "package.json");
|
||||
function ensureDirs(): string {
|
||||
const { adapterPluginsDir } = adapterPluginPaths();
|
||||
fs.mkdirSync(adapterPluginsDir, { recursive: true });
|
||||
const pkgJsonPath = path.join(adapterPluginsDir, "package.json");
|
||||
if (!fs.existsSync(pkgJsonPath)) {
|
||||
fs.writeFileSync(pkgJsonPath, JSON.stringify({
|
||||
name: "paperclip-adapter-plugins",
|
||||
|
|
@ -70,44 +75,55 @@ function ensureDirs(): void {
|
|||
description: "Managed directory for Paperclip external adapter plugins. Do not edit manually.",
|
||||
}, null, 2) + "\n");
|
||||
}
|
||||
return adapterPluginsDir;
|
||||
}
|
||||
|
||||
function readStore(): AdapterPluginRecord[] {
|
||||
if (storeCache) return storeCache;
|
||||
const { adapterPluginsStorePath } = adapterPluginPaths();
|
||||
if (storeCache?.path === adapterPluginsStorePath) return storeCache.records;
|
||||
try {
|
||||
const raw = fs.readFileSync(ADAPTER_PLUGINS_STORE_PATH, "utf-8");
|
||||
const raw = fs.readFileSync(adapterPluginsStorePath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
storeCache = Array.isArray(parsed) ? (parsed as AdapterPluginRecord[]) : [];
|
||||
storeCache = {
|
||||
path: adapterPluginsStorePath,
|
||||
records: Array.isArray(parsed) ? (parsed as AdapterPluginRecord[]) : [],
|
||||
};
|
||||
} catch {
|
||||
storeCache = [];
|
||||
storeCache = { path: adapterPluginsStorePath, records: [] };
|
||||
}
|
||||
return storeCache;
|
||||
return storeCache.records;
|
||||
}
|
||||
|
||||
function writeStore(records: AdapterPluginRecord[]): void {
|
||||
ensureDirs();
|
||||
fs.writeFileSync(ADAPTER_PLUGINS_STORE_PATH, JSON.stringify(records, null, 2), "utf-8");
|
||||
storeCache = records;
|
||||
const { adapterPluginsStorePath } = adapterPluginPaths();
|
||||
fs.writeFileSync(adapterPluginsStorePath, JSON.stringify(records, null, 2), "utf-8");
|
||||
storeCache = { path: adapterPluginsStorePath, records };
|
||||
}
|
||||
|
||||
function readSettings(): AdapterSettings {
|
||||
if (settingsCache) return settingsCache;
|
||||
const { adapterSettingsPath } = adapterPluginPaths();
|
||||
if (settingsCache?.path === adapterSettingsPath) return settingsCache.settings;
|
||||
try {
|
||||
const raw = fs.readFileSync(ADAPTER_SETTINGS_PATH, "utf-8");
|
||||
const raw = fs.readFileSync(adapterSettingsPath, "utf-8");
|
||||
const parsed = JSON.parse(raw);
|
||||
settingsCache = parsed && Array.isArray(parsed.disabledTypes)
|
||||
? (parsed as AdapterSettings)
|
||||
: { disabledTypes: [] };
|
||||
settingsCache = {
|
||||
path: adapterSettingsPath,
|
||||
settings: parsed && Array.isArray(parsed.disabledTypes)
|
||||
? (parsed as AdapterSettings)
|
||||
: { disabledTypes: [] },
|
||||
};
|
||||
} catch {
|
||||
settingsCache = { disabledTypes: [] };
|
||||
settingsCache = { path: adapterSettingsPath, settings: { disabledTypes: [] } };
|
||||
}
|
||||
return settingsCache;
|
||||
return settingsCache.settings;
|
||||
}
|
||||
|
||||
function writeSettings(settings: AdapterSettings): void {
|
||||
ensureDirs();
|
||||
fs.writeFileSync(ADAPTER_SETTINGS_PATH, JSON.stringify(settings, null, 2), "utf-8");
|
||||
settingsCache = settings;
|
||||
const { adapterSettingsPath } = adapterPluginPaths();
|
||||
fs.writeFileSync(adapterSettingsPath, JSON.stringify(settings, null, 2), "utf-8");
|
||||
settingsCache = { path: adapterSettingsPath, settings };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
@ -143,8 +159,7 @@ export function getAdapterPluginByType(type: string): AdapterPluginRecord | unde
|
|||
}
|
||||
|
||||
export function getAdapterPluginsDir(): string {
|
||||
ensureDirs();
|
||||
return ADAPTER_PLUGINS_DIR;
|
||||
return ensureDirs();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
48
server/src/services/agent-start-lock.ts
Normal file
48
server/src/services/agent-start-lock.ts
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
import { logger } from "../middleware/logger.js";
|
||||
|
||||
const AGENT_START_LOCK_STALE_MS = 30_000;
|
||||
const startLocksByAgent = new Map<string, { promise: Promise<void>; startedAtMs: number }>();
|
||||
|
||||
async function waitForAgentStartLock(agentId: string, lock: { promise: Promise<void>; startedAtMs: number }) {
|
||||
const elapsedMs = Date.now() - lock.startedAtMs;
|
||||
const remainingMs = AGENT_START_LOCK_STALE_MS - elapsedMs;
|
||||
if (remainingMs <= 0) {
|
||||
logger.warn({ agentId, staleMs: elapsedMs }, "agent start lock stale; continuing queued-run start");
|
||||
return;
|
||||
}
|
||||
|
||||
let timedOut = false;
|
||||
let timeout: ReturnType<typeof setTimeout> | null = null;
|
||||
await Promise.race([
|
||||
lock.promise,
|
||||
new Promise<void>((resolve) => {
|
||||
timeout = setTimeout(() => {
|
||||
timedOut = true;
|
||||
resolve();
|
||||
}, remainingMs);
|
||||
}),
|
||||
]);
|
||||
if (timeout) clearTimeout(timeout);
|
||||
|
||||
if (timedOut) {
|
||||
logger.warn({ agentId, staleMs: AGENT_START_LOCK_STALE_MS }, "agent start lock timed out; continuing queued-run start");
|
||||
}
|
||||
}
|
||||
|
||||
export async function withAgentStartLock<T>(agentId: string, fn: () => Promise<T>) {
|
||||
const previous = startLocksByAgent.get(agentId);
|
||||
const waitForPrevious = previous ? waitForAgentStartLock(agentId, previous) : Promise.resolve();
|
||||
const run = waitForPrevious.then(fn);
|
||||
const marker = run.then(
|
||||
() => undefined,
|
||||
() => undefined,
|
||||
);
|
||||
startLocksByAgent.set(agentId, { promise: marker, startedAtMs: Date.now() });
|
||||
try {
|
||||
return await run;
|
||||
} finally {
|
||||
if (startLocksByAgent.get(agentId)?.promise === marker) {
|
||||
startLocksByAgent.delete(agentId);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load diff
|
|
@ -32,7 +32,7 @@ export { routineService } from "./routines.js";
|
|||
export { costService } from "./costs.js";
|
||||
export { financeService } from "./finance.js";
|
||||
export { heartbeatService } from "./heartbeat.js";
|
||||
export { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./issue-liveness.js";
|
||||
export { classifyIssueGraphLiveness, type IssueLivenessFinding } from "./recovery/index.js";
|
||||
export { dashboardService } from "./dashboard.js";
|
||||
export { sidebarBadgeService } from "./sidebar-badges.js";
|
||||
export { sidebarPreferenceService } from "./sidebar-preferences.js";
|
||||
|
|
|
|||
|
|
@ -41,12 +41,14 @@ function normalizeExperimentalSettings(raw: unknown): InstanceExperimentalSettin
|
|||
enableEnvironments: parsed.data.enableEnvironments ?? false,
|
||||
enableIsolatedWorkspaces: parsed.data.enableIsolatedWorkspaces ?? false,
|
||||
autoRestartDevServerWhenIdle: parsed.data.autoRestartDevServerWhenIdle ?? false,
|
||||
enableIssueGraphLivenessAutoRecovery: parsed.data.enableIssueGraphLivenessAutoRecovery ?? false,
|
||||
};
|
||||
}
|
||||
return {
|
||||
enableEnvironments: false,
|
||||
enableIsolatedWorkspaces: false,
|
||||
autoRestartDevServerWhenIdle: false,
|
||||
enableIssueGraphLivenessAutoRecovery: false,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,324 +1,16 @@
|
|||
export type IssueLivenessSeverity = "warning" | "critical";
|
||||
|
||||
export type IssueLivenessState =
|
||||
| "blocked_by_unassigned_issue"
|
||||
| "blocked_by_uninvokable_assignee"
|
||||
| "blocked_by_cancelled_issue"
|
||||
| "invalid_review_participant";
|
||||
|
||||
export interface IssueLivenessIssueInput {
|
||||
id: string;
|
||||
companyId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
projectId?: string | null;
|
||||
goalId?: string | null;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface IssueLivenessRelationInput {
|
||||
companyId: string;
|
||||
blockerIssueId: string;
|
||||
blockedIssueId: string;
|
||||
}
|
||||
|
||||
export interface IssueLivenessAgentInput {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
title?: string | null;
|
||||
status: string;
|
||||
reportsTo?: string | null;
|
||||
}
|
||||
|
||||
export interface IssueLivenessExecutionPathInput {
|
||||
companyId: string;
|
||||
issueId: string | null;
|
||||
agentId?: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface IssueLivenessDependencyPathEntry {
|
||||
issueId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface IssueLivenessFinding {
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
identifier: string | null;
|
||||
state: IssueLivenessState;
|
||||
severity: IssueLivenessSeverity;
|
||||
reason: string;
|
||||
dependencyPath: IssueLivenessDependencyPathEntry[];
|
||||
recommendedOwnerAgentId: string | null;
|
||||
recommendedOwnerCandidateAgentIds: string[];
|
||||
recommendedAction: string;
|
||||
incidentKey: string;
|
||||
}
|
||||
|
||||
export interface IssueGraphLivenessInput {
|
||||
issues: IssueLivenessIssueInput[];
|
||||
relations: IssueLivenessRelationInput[];
|
||||
agents: IssueLivenessAgentInput[];
|
||||
activeRuns?: IssueLivenessExecutionPathInput[];
|
||||
queuedWakeRequests?: IssueLivenessExecutionPathInput[];
|
||||
}
|
||||
|
||||
const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
const BLOCKING_AGENT_STATUSES = new Set(["paused", "terminated", "pending_approval"]);
|
||||
|
||||
function issueLabel(issue: IssueLivenessIssueInput) {
|
||||
return issue.identifier ?? issue.id;
|
||||
}
|
||||
|
||||
function pathEntry(issue: IssueLivenessIssueInput): IssueLivenessDependencyPathEntry {
|
||||
return {
|
||||
issueId: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
};
|
||||
}
|
||||
|
||||
function isInvokableAgent(agent: IssueLivenessAgentInput | null | undefined) {
|
||||
return Boolean(agent && INVOKABLE_AGENT_STATUSES.has(agent.status));
|
||||
}
|
||||
|
||||
function hasActiveExecutionPath(
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
activeRuns: IssueLivenessExecutionPathInput[],
|
||||
queuedWakeRequests: IssueLivenessExecutionPathInput[],
|
||||
) {
|
||||
return [...activeRuns, ...queuedWakeRequests].some(
|
||||
(entry) => entry.companyId === companyId && entry.issueId === issueId,
|
||||
);
|
||||
}
|
||||
|
||||
function readPrincipalAgentId(principal: unknown): string | null {
|
||||
if (!principal || typeof principal !== "object") return null;
|
||||
const value = principal as Record<string, unknown>;
|
||||
return value.type === "agent" && typeof value.agentId === "string" && value.agentId.length > 0
|
||||
? value.agentId
|
||||
: null;
|
||||
}
|
||||
|
||||
function principalIsResolvableUser(principal: unknown): boolean {
|
||||
if (!principal || typeof principal !== "object") return false;
|
||||
const value = principal as Record<string, unknown>;
|
||||
return value.type === "user" && typeof value.userId === "string" && value.userId.length > 0;
|
||||
}
|
||||
|
||||
function agentChainCandidates(
|
||||
startAgentId: string | null | undefined,
|
||||
agentsById: Map<string, IssueLivenessAgentInput>,
|
||||
companyId: string,
|
||||
) {
|
||||
const candidates: string[] = [];
|
||||
const seen = new Set<string>();
|
||||
let current = startAgentId ? agentsById.get(startAgentId) : null;
|
||||
|
||||
while (current?.reportsTo) {
|
||||
if (seen.has(current.reportsTo)) break;
|
||||
seen.add(current.reportsTo);
|
||||
const manager = agentsById.get(current.reportsTo);
|
||||
if (!manager || manager.companyId !== companyId) break;
|
||||
if (isInvokableAgent(manager)) candidates.push(manager.id);
|
||||
current = manager;
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function fallbackExecutiveCandidates(agents: IssueLivenessAgentInput[], companyId: string) {
|
||||
const active = agents.filter((agent) => agent.companyId === companyId && isInvokableAgent(agent));
|
||||
const executive = active.filter((agent) => {
|
||||
const haystack = `${agent.role} ${agent.title ?? ""} ${agent.name}`.toLowerCase();
|
||||
return /\b(cto|chief technology|ceo|chief executive)\b/.test(haystack);
|
||||
});
|
||||
const roots = active.filter((agent) => !agent.reportsTo);
|
||||
return [...executive, ...roots, ...active].map((agent) => agent.id);
|
||||
}
|
||||
|
||||
function ownerCandidatesForIssue(
|
||||
issue: IssueLivenessIssueInput,
|
||||
agents: IssueLivenessAgentInput[],
|
||||
agentsById: Map<string, IssueLivenessAgentInput>,
|
||||
) {
|
||||
const candidates = [
|
||||
...agentChainCandidates(issue.assigneeAgentId, agentsById, issue.companyId),
|
||||
...agentChainCandidates(issue.createdByAgentId, agentsById, issue.companyId),
|
||||
...fallbackExecutiveCandidates(agents, issue.companyId),
|
||||
];
|
||||
return [...new Set(candidates)];
|
||||
}
|
||||
|
||||
function incidentKey(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
state: IssueLivenessState;
|
||||
blockerIssueId?: string | null;
|
||||
participantAgentId?: string | null;
|
||||
}) {
|
||||
return [
|
||||
"harness_liveness",
|
||||
input.companyId,
|
||||
input.issueId,
|
||||
input.state,
|
||||
input.blockerIssueId ?? input.participantAgentId ?? "none",
|
||||
].join(":");
|
||||
}
|
||||
|
||||
function finding(input: {
|
||||
issue: IssueLivenessIssueInput;
|
||||
state: IssueLivenessState;
|
||||
severity?: IssueLivenessSeverity;
|
||||
reason: string;
|
||||
dependencyPath: IssueLivenessIssueInput[];
|
||||
recommendedOwnerCandidateAgentIds: string[];
|
||||
recommendedAction: string;
|
||||
blockerIssueId?: string | null;
|
||||
participantAgentId?: string | null;
|
||||
}): IssueLivenessFinding {
|
||||
return {
|
||||
issueId: input.issue.id,
|
||||
companyId: input.issue.companyId,
|
||||
identifier: input.issue.identifier,
|
||||
state: input.state,
|
||||
severity: input.severity ?? "critical",
|
||||
reason: input.reason,
|
||||
dependencyPath: input.dependencyPath.map(pathEntry),
|
||||
recommendedOwnerAgentId: input.recommendedOwnerCandidateAgentIds[0] ?? null,
|
||||
recommendedOwnerCandidateAgentIds: input.recommendedOwnerCandidateAgentIds,
|
||||
recommendedAction: input.recommendedAction,
|
||||
incidentKey: incidentKey({
|
||||
companyId: input.issue.companyId,
|
||||
issueId: input.issue.id,
|
||||
state: input.state,
|
||||
blockerIssueId: input.blockerIssueId,
|
||||
participantAgentId: input.participantAgentId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): IssueLivenessFinding[] {
|
||||
const issuesById = new Map(input.issues.map((issue) => [issue.id, issue]));
|
||||
const agentsById = new Map(input.agents.map((agent) => [agent.id, agent]));
|
||||
const blockersByBlockedIssueId = new Map<string, IssueLivenessRelationInput[]>();
|
||||
const findings: IssueLivenessFinding[] = [];
|
||||
const activeRuns = input.activeRuns ?? [];
|
||||
const queuedWakeRequests = input.queuedWakeRequests ?? [];
|
||||
|
||||
for (const relation of input.relations) {
|
||||
const list = blockersByBlockedIssueId.get(relation.blockedIssueId) ?? [];
|
||||
list.push(relation);
|
||||
blockersByBlockedIssueId.set(relation.blockedIssueId, list);
|
||||
}
|
||||
|
||||
for (const issue of input.issues) {
|
||||
const ownerCandidates = ownerCandidatesForIssue(issue, input.agents, agentsById);
|
||||
|
||||
if (issue.status === "blocked") {
|
||||
const relations = blockersByBlockedIssueId.get(issue.id) ?? [];
|
||||
for (const relation of relations) {
|
||||
if (relation.companyId !== issue.companyId) continue;
|
||||
const blocker = issuesById.get(relation.blockerIssueId);
|
||||
if (!blocker || blocker.companyId !== issue.companyId || blocker.status === "done") continue;
|
||||
|
||||
if (blocker.status === "cancelled") {
|
||||
findings.push(finding({
|
||||
issue,
|
||||
state: "blocked_by_cancelled_issue",
|
||||
reason: `${issueLabel(issue)} is still blocked by cancelled issue ${issueLabel(blocker)}.`,
|
||||
dependencyPath: [issue, blocker],
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Inspect ${issueLabel(blocker)} and either remove it from ${issueLabel(issue)}'s blockers or replace it with an actionable unblock issue.`,
|
||||
blockerIssueId: blocker.id,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!blocker.assigneeAgentId && !blocker.assigneeUserId) {
|
||||
if (hasActiveExecutionPath(issue.companyId, blocker.id, activeRuns, queuedWakeRequests)) continue;
|
||||
findings.push(finding({
|
||||
issue,
|
||||
state: "blocked_by_unassigned_issue",
|
||||
reason: `${issueLabel(issue)} is blocked by unassigned issue ${issueLabel(blocker)} with no user owner.`,
|
||||
dependencyPath: [issue, blocker],
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Assign ${issueLabel(blocker)} to an owner who can complete it, or remove it from ${issueLabel(issue)}'s blockers if it is no longer required.`,
|
||||
blockerIssueId: blocker.id,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!blocker.assigneeAgentId) continue;
|
||||
if (hasActiveExecutionPath(issue.companyId, blocker.id, activeRuns, queuedWakeRequests)) continue;
|
||||
|
||||
const blockerAgent = agentsById.get(blocker.assigneeAgentId);
|
||||
if (!blockerAgent || blockerAgent.companyId !== issue.companyId || BLOCKING_AGENT_STATUSES.has(blockerAgent.status)) {
|
||||
findings.push(finding({
|
||||
issue,
|
||||
state: "blocked_by_uninvokable_assignee",
|
||||
reason: blockerAgent
|
||||
? `${issueLabel(issue)} is blocked by ${issueLabel(blocker)}, but its assignee is ${blockerAgent.status}.`
|
||||
: `${issueLabel(issue)} is blocked by ${issueLabel(blocker)}, but its assignee no longer exists.`,
|
||||
dependencyPath: [issue, blocker],
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Review ${issueLabel(blocker)} and assign it to an active owner or replace the blocker with an actionable issue.`,
|
||||
blockerIssueId: blocker.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (issue.status !== "in_review" || !issue.executionState) continue;
|
||||
const participant = issue.executionState.currentParticipant;
|
||||
const participantAgentId = readPrincipalAgentId(participant);
|
||||
if (participantAgentId) {
|
||||
const participantAgent = agentsById.get(participantAgentId);
|
||||
if (!isInvokableAgent(participantAgent) || participantAgent?.companyId !== issue.companyId) {
|
||||
findings.push(finding({
|
||||
issue,
|
||||
state: "invalid_review_participant",
|
||||
reason: participantAgent
|
||||
? `${issueLabel(issue)} is in review, but current participant agent is ${participantAgent.status}.`
|
||||
: `${issueLabel(issue)} is in review, but current participant agent cannot be resolved.`,
|
||||
dependencyPath: [issue],
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Repair ${issueLabel(issue)}'s review participant or return the issue to an active assignee with a clear change request.`,
|
||||
participantAgentId,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!principalIsResolvableUser(participant)) {
|
||||
findings.push(finding({
|
||||
issue,
|
||||
state: "invalid_review_participant",
|
||||
reason: `${issueLabel(issue)} is in review, but its current participant cannot be resolved.`,
|
||||
dependencyPath: [issue],
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Repair ${issueLabel(issue)}'s review participant or return the issue to an active assignee with a clear change request.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
export {
|
||||
classifyIssueGraphLiveness,
|
||||
} from "./recovery/issue-graph-liveness.js";
|
||||
export type {
|
||||
IssueGraphLivenessInput,
|
||||
IssueLivenessAgentInput,
|
||||
IssueLivenessDependencyPathEntry,
|
||||
IssueLivenessExecutionPathInput,
|
||||
IssueLivenessFinding,
|
||||
IssueLivenessIssueInput,
|
||||
IssueLivenessOwnerCandidate,
|
||||
IssueLivenessOwnerCandidateReason,
|
||||
IssueLivenessRelationInput,
|
||||
IssueLivenessSeverity,
|
||||
IssueLivenessState,
|
||||
} from "./recovery/issue-graph-liveness.js";
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import type { Db } from "@paperclipai/db";
|
|||
import {
|
||||
agentWakeupRequests,
|
||||
heartbeatRuns,
|
||||
issueComments,
|
||||
issueTreeHoldMembers,
|
||||
issueTreeHolds,
|
||||
issues,
|
||||
|
|
@ -76,6 +77,151 @@ export const ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS: ReadonlySet<string> =
|
|||
"issue_reopened_via_comment",
|
||||
"issue_comment_mentioned",
|
||||
] as const);
|
||||
const ISSUE_TREE_CONTROL_INTERACTION_WAKE_SOURCES: Readonly<Record<string, ReadonlySet<string>>> = {
|
||||
issue_commented: new Set(["issue.comment"]),
|
||||
issue_reopened_via_comment: new Set(["issue.comment.reopen"]),
|
||||
issue_comment_mentioned: new Set(["comment.mention"]),
|
||||
};
|
||||
|
||||
type VerifiedInteractionActor = {
|
||||
requestedByActorType?: string | null;
|
||||
requestedByActorId?: string | null;
|
||||
};
|
||||
|
||||
function readNonEmptyStringFromRecord(record: unknown, key: string) {
|
||||
if (!record || typeof record !== "object") return null;
|
||||
const value = (record as Record<string, unknown>)[key];
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readInteractionWakeCommentId(record: unknown) {
|
||||
if (!record || typeof record !== "object") return null;
|
||||
const value = (record as Record<string, unknown>).wakeCommentIds;
|
||||
if (Array.isArray(value)) {
|
||||
const latest = value
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||
.at(-1);
|
||||
if (latest) return latest.trim();
|
||||
}
|
||||
return readNonEmptyStringFromRecord(record, "wakeCommentId") ?? readNonEmptyStringFromRecord(record, "commentId");
|
||||
}
|
||||
|
||||
function hasVerifiedInteractionSource(wakeReason: string, contextSnapshot: Record<string, unknown>) {
|
||||
const source = readNonEmptyStringFromRecord(contextSnapshot, "source");
|
||||
if (!source) return false;
|
||||
return ISSUE_TREE_CONTROL_INTERACTION_WAKE_SOURCES[wakeReason]?.has(source) ?? false;
|
||||
}
|
||||
|
||||
function actorMatchesComment(
|
||||
actor: VerifiedInteractionActor,
|
||||
comment: { authorAgentId: string | null; authorUserId: string | null },
|
||||
) {
|
||||
if (!actor.requestedByActorType) return false;
|
||||
if (actor.requestedByActorType === "system") return true;
|
||||
if (!actor.requestedByActorId) return false;
|
||||
if (actor.requestedByActorType === "agent") return comment.authorAgentId === actor.requestedByActorId;
|
||||
if (actor.requestedByActorType === "user") return comment.authorUserId === actor.requestedByActorId;
|
||||
return false;
|
||||
}
|
||||
|
||||
async function hasVerifiedInteractionWakeRequest(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
input: {
|
||||
companyId: string;
|
||||
agentId?: string | null;
|
||||
runId?: string | null;
|
||||
wakeupRequestId?: string | null;
|
||||
issueId: string;
|
||||
commentId: string;
|
||||
comment: { authorAgentId: string | null; authorUserId: string | null };
|
||||
},
|
||||
) {
|
||||
if (!input.runId && !input.wakeupRequestId) return false;
|
||||
const predicates = [
|
||||
eq(agentWakeupRequests.companyId, input.companyId),
|
||||
sql`${agentWakeupRequests.payload} ->> 'issueId' = ${input.issueId}`,
|
||||
sql`${agentWakeupRequests.payload} ->> 'commentId' = ${input.commentId}`,
|
||||
];
|
||||
if (input.agentId) predicates.push(eq(agentWakeupRequests.agentId, input.agentId));
|
||||
if (input.runId && input.wakeupRequestId) {
|
||||
const requestScope = or(
|
||||
eq(agentWakeupRequests.runId, input.runId),
|
||||
eq(agentWakeupRequests.id, input.wakeupRequestId),
|
||||
);
|
||||
if (requestScope) predicates.push(requestScope);
|
||||
} else if (input.runId) {
|
||||
predicates.push(eq(agentWakeupRequests.runId, input.runId));
|
||||
} else if (input.wakeupRequestId) {
|
||||
predicates.push(eq(agentWakeupRequests.id, input.wakeupRequestId));
|
||||
}
|
||||
|
||||
const requests = await dbOrTx
|
||||
.select({
|
||||
requestedByActorType: agentWakeupRequests.requestedByActorType,
|
||||
requestedByActorId: agentWakeupRequests.requestedByActorId,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(and(...predicates));
|
||||
|
||||
return requests.some((request) => actorMatchesComment(request, input.comment));
|
||||
}
|
||||
|
||||
export async function isVerifiedIssueTreeControlInteractionWake(
|
||||
dbOrTx: Pick<Db, "select">,
|
||||
input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
agentId?: string | null;
|
||||
contextSnapshot: Record<string, unknown> | null | undefined;
|
||||
requestedByActorType?: "user" | "agent" | "system" | string | null;
|
||||
requestedByActorId?: string | null;
|
||||
runId?: string | null;
|
||||
wakeupRequestId?: string | null;
|
||||
},
|
||||
) {
|
||||
const contextSnapshot = input.contextSnapshot ?? null;
|
||||
const wakeReason =
|
||||
readNonEmptyStringFromRecord(contextSnapshot, "wakeReason") ??
|
||||
readNonEmptyStringFromRecord(contextSnapshot, "reason");
|
||||
if (!wakeReason || !ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS.has(wakeReason)) return false;
|
||||
if (!contextSnapshot || !hasVerifiedInteractionSource(wakeReason, contextSnapshot)) return false;
|
||||
|
||||
const commentId = readInteractionWakeCommentId(contextSnapshot);
|
||||
if (!commentId) return false;
|
||||
|
||||
const comment = await dbOrTx
|
||||
.select({
|
||||
id: issueComments.id,
|
||||
authorAgentId: issueComments.authorAgentId,
|
||||
authorUserId: issueComments.authorUserId,
|
||||
})
|
||||
.from(issueComments)
|
||||
.where(
|
||||
and(
|
||||
eq(issueComments.companyId, input.companyId),
|
||||
eq(issueComments.issueId, input.issueId),
|
||||
eq(issueComments.id, commentId),
|
||||
),
|
||||
)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (!comment) return false;
|
||||
|
||||
const directActor = {
|
||||
requestedByActorType: input.requestedByActorType,
|
||||
requestedByActorId: input.requestedByActorId,
|
||||
};
|
||||
if (actorMatchesComment(directActor, comment)) return true;
|
||||
|
||||
return hasVerifiedInteractionWakeRequest(dbOrTx, {
|
||||
companyId: input.companyId,
|
||||
agentId: input.agentId,
|
||||
runId: input.runId,
|
||||
wakeupRequestId: input.wakeupRequestId,
|
||||
issueId: input.issueId,
|
||||
commentId,
|
||||
comment,
|
||||
});
|
||||
}
|
||||
|
||||
function normalizeReleasePolicy(
|
||||
releasePolicy: IssueTreeHoldReleasePolicy | null | undefined,
|
||||
|
|
|
|||
|
|
@ -3,6 +3,7 @@ import { and, asc, desc, eq, inArray, isNull, ne, or, sql } from "drizzle-orm";
|
|||
import type { Db } from "@paperclipai/db";
|
||||
import {
|
||||
activityLog,
|
||||
agentWakeupRequests,
|
||||
agents,
|
||||
assets,
|
||||
companies,
|
||||
|
|
@ -23,7 +24,7 @@ import {
|
|||
projectWorkspaces,
|
||||
projects,
|
||||
} from "@paperclipai/db";
|
||||
import type { IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||
import type { IssueBlockerAttention, IssueRelationIssueSummary } from "@paperclipai/shared";
|
||||
import { extractAgentMentionIds, extractProjectMentionIds, isUuidLike } from "@paperclipai/shared";
|
||||
import { conflict, notFound, unprocessable } from "../errors.js";
|
||||
import {
|
||||
|
|
@ -38,7 +39,7 @@ import { redactCurrentUserText } from "../log-redaction.js";
|
|||
import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallback.js";
|
||||
import { getDefaultCompanyGoal } from "./goals.js";
|
||||
import {
|
||||
ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS,
|
||||
isVerifiedIssueTreeControlInteractionWake,
|
||||
issueTreeControlService,
|
||||
type ActiveIssueTreePauseHoldGate,
|
||||
} from "./issue-tree-control.js";
|
||||
|
|
@ -82,18 +83,6 @@ function readStringFromRecord(record: unknown, key: string) {
|
|||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readLatestWakeCommentId(record: unknown) {
|
||||
if (!record || typeof record !== "object") return null;
|
||||
const value = (record as Record<string, unknown>).wakeCommentIds;
|
||||
if (Array.isArray(value)) {
|
||||
const latest = value
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||
.at(-1);
|
||||
if (latest) return latest.trim();
|
||||
}
|
||||
return readStringFromRecord(record, "wakeCommentId") ?? readStringFromRecord(record, "commentId");
|
||||
}
|
||||
|
||||
export interface IssueFilters {
|
||||
status?: string;
|
||||
assigneeAgentId?: string;
|
||||
|
|
@ -668,6 +657,46 @@ async function withIssueLabels(dbOrTx: any, rows: IssueRow[]): Promise<IssueWith
|
|||
}
|
||||
|
||||
const ACTIVE_RUN_STATUSES = ["queued", "running"];
|
||||
const BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES = ["queued", "running"];
|
||||
const BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES = ["queued", "deferred_issue_execution"];
|
||||
const BLOCKER_ATTENTION_MAX_DEPTH = 8;
|
||||
const BLOCKER_ATTENTION_MAX_NODES = 2000;
|
||||
const BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
|
||||
type IssueBlockerAttentionNode = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
parentId: string | null;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
executionRunId?: string | null;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
type IssueBlockerAttentionInputNode =
|
||||
Pick<
|
||||
IssueBlockerAttentionNode,
|
||||
"id" | "companyId" | "parentId" | "identifier" | "title" | "status" | "assigneeAgentId" | "assigneeUserId"
|
||||
>
|
||||
& { executionRunId?: string | null };
|
||||
|
||||
type IssueBlockerAttentionEdge = {
|
||||
issueId: string;
|
||||
blockerIssueId: string;
|
||||
};
|
||||
type IssueBlockerAttentionQueryRow = IssueBlockerAttentionNode & {
|
||||
issueId: string | null;
|
||||
blockerIssueId: string;
|
||||
};
|
||||
type IssueBlockerAttentionActivePathRow = {
|
||||
issueId: string | null;
|
||||
};
|
||||
type IssueBlockerAttentionAgentRow = {
|
||||
id: string;
|
||||
companyId: string;
|
||||
status: string;
|
||||
};
|
||||
|
||||
async function activeRunMapForIssues(
|
||||
dbOrTx: any,
|
||||
|
|
@ -706,6 +735,380 @@ async function activeRunMapForIssues(
|
|||
return map;
|
||||
}
|
||||
|
||||
function createIssueBlockerAttention(input: Partial<IssueBlockerAttention> = {}): IssueBlockerAttention {
|
||||
return {
|
||||
state: input.state ?? "none",
|
||||
reason: input.reason ?? null,
|
||||
unresolvedBlockerCount: input.unresolvedBlockerCount ?? 0,
|
||||
coveredBlockerCount: input.coveredBlockerCount ?? 0,
|
||||
attentionBlockerCount: input.attentionBlockerCount ?? 0,
|
||||
sampleBlockerIdentifier: input.sampleBlockerIdentifier ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
function blockerSampleIdentifier(node: IssueBlockerAttentionNode | null | undefined) {
|
||||
return node?.identifier ?? node?.id ?? null;
|
||||
}
|
||||
|
||||
function appendBlockerAttentionEdges(
|
||||
edgesByIssueId: Map<string, IssueBlockerAttentionEdge[]>,
|
||||
rows: IssueBlockerAttentionEdge[],
|
||||
) {
|
||||
for (const row of rows) {
|
||||
const existing = edgesByIssueId.get(row.issueId) ?? [];
|
||||
if (!existing.some((edge) => edge.blockerIssueId === row.blockerIssueId)) {
|
||||
existing.push(row);
|
||||
edgesByIssueId.set(row.issueId, existing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
type IssueRelationSummaryRow = {
|
||||
relatedId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
priority: string;
|
||||
assigneeAgentId: string | null;
|
||||
assigneeUserId: string | null;
|
||||
};
|
||||
|
||||
function summarizeIssueRelationRow(row: IssueRelationSummaryRow): IssueRelationIssueSummary {
|
||||
return {
|
||||
id: row.relatedId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status as IssueRelationIssueSummary["status"],
|
||||
priority: row.priority as IssueRelationIssueSummary["priority"],
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
};
|
||||
}
|
||||
|
||||
async function terminalExplicitBlockersByRoot(
|
||||
companyId: string,
|
||||
roots: IssueRelationIssueSummary[],
|
||||
dbOrTx: DbReader,
|
||||
): Promise<Map<string, IssueRelationIssueSummary[]>> {
|
||||
const rootIds = [...new Set(roots.map((root) => root.id))];
|
||||
const terminalByRoot = new Map<string, IssueRelationIssueSummary[]>();
|
||||
if (rootIds.length === 0) return terminalByRoot;
|
||||
|
||||
const nodesById = new Map<string, IssueRelationIssueSummary>();
|
||||
const edgesByIssueId = new Map<string, string[]>();
|
||||
for (const root of roots) nodesById.set(root.id, root);
|
||||
|
||||
let frontier = rootIds;
|
||||
for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) {
|
||||
const nextFrontier = new Set<string>();
|
||||
for (const chunk of chunkList([...new Set(frontier)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const rows = await dbOrTx
|
||||
.select({
|
||||
currentIssueId: issueRelations.relatedIssueId,
|
||||
relatedId: issues.id,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
priority: issues.priority,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issueRelations)
|
||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueRelations.companyId, companyId),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
inArray(issueRelations.relatedIssueId, chunk),
|
||||
eq(issues.companyId, companyId),
|
||||
ne(issues.status, "done"),
|
||||
),
|
||||
);
|
||||
|
||||
for (const row of rows) {
|
||||
const existingEdges = edgesByIssueId.get(row.currentIssueId) ?? [];
|
||||
if (!existingEdges.includes(row.relatedId)) {
|
||||
existingEdges.push(row.relatedId);
|
||||
edgesByIssueId.set(row.currentIssueId, existingEdges);
|
||||
}
|
||||
if (!nodesById.has(row.relatedId)) {
|
||||
nodesById.set(row.relatedId, summarizeIssueRelationRow(row));
|
||||
nextFrontier.add(row.relatedId);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) break;
|
||||
frontier = [...nextFrontier];
|
||||
}
|
||||
|
||||
const collectTerminal = (issueId: string, seen: Set<string>): IssueRelationIssueSummary[] => {
|
||||
if (seen.has(issueId)) return [];
|
||||
const node = nodesById.get(issueId);
|
||||
if (!node || node.status === "done") return [];
|
||||
const nextSeen = new Set(seen);
|
||||
nextSeen.add(issueId);
|
||||
const downstreamIds = edgesByIssueId.get(issueId) ?? [];
|
||||
if (downstreamIds.length === 0) return [node];
|
||||
return downstreamIds.flatMap((downstreamId) => collectTerminal(downstreamId, nextSeen));
|
||||
};
|
||||
|
||||
for (const rootId of rootIds) {
|
||||
const deduped = new Map<string, IssueRelationIssueSummary>();
|
||||
for (const blocker of collectTerminal(rootId, new Set())) {
|
||||
if (blocker.id !== rootId) deduped.set(blocker.id, blocker);
|
||||
}
|
||||
if (deduped.size > 0) {
|
||||
terminalByRoot.set(rootId, [...deduped.values()].sort((a, b) => a.title.localeCompare(b.title)));
|
||||
}
|
||||
}
|
||||
|
||||
return terminalByRoot;
|
||||
}
|
||||
|
||||
async function listIssueBlockerAttentionMap(
|
||||
dbOrTx: any,
|
||||
companyId: string,
|
||||
issueRows: IssueBlockerAttentionInputNode[],
|
||||
): Promise<Map<string, IssueBlockerAttention>> {
|
||||
const roots = issueRows.filter((row) => row.companyId === companyId && row.status === "blocked");
|
||||
const attentionMap = new Map<string, IssueBlockerAttention>();
|
||||
for (const row of issueRows) {
|
||||
if (row.status !== "blocked") {
|
||||
attentionMap.set(row.id, createIssueBlockerAttention());
|
||||
}
|
||||
}
|
||||
if (roots.length === 0) return attentionMap;
|
||||
|
||||
const nodesById = new Map<string, IssueBlockerAttentionNode>();
|
||||
const edgesByIssueId = new Map<string, IssueBlockerAttentionEdge[]>();
|
||||
for (const root of roots) nodesById.set(root.id, { ...root });
|
||||
|
||||
let frontier = roots.map((root) => root.id);
|
||||
let truncated = false;
|
||||
for (let depth = 0; frontier.length > 0 && depth < BLOCKER_ATTENTION_MAX_DEPTH; depth += 1) {
|
||||
const nextFrontier = new Set<string>();
|
||||
|
||||
for (const chunk of chunkList([...new Set(frontier)], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const explicitBlockerRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
|
||||
.select({
|
||||
issueId: issueRelations.relatedIssueId,
|
||||
blockerIssueId: issues.id,
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
parentId: issues.parentId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
executionRunId: issues.executionRunId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issueRelations)
|
||||
.innerJoin(issues, eq(issueRelations.issueId, issues.id))
|
||||
.where(
|
||||
and(
|
||||
eq(issueRelations.companyId, companyId),
|
||||
eq(issueRelations.type, "blocks"),
|
||||
inArray(issueRelations.relatedIssueId, chunk),
|
||||
eq(issues.companyId, companyId),
|
||||
ne(issues.status, "done"),
|
||||
),
|
||||
);
|
||||
const childRowsPromise: Promise<IssueBlockerAttentionQueryRow[]> = dbOrTx
|
||||
.select({
|
||||
issueId: issues.parentId,
|
||||
blockerIssueId: issues.id,
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
parentId: issues.parentId,
|
||||
identifier: issues.identifier,
|
||||
title: issues.title,
|
||||
status: issues.status,
|
||||
executionRunId: issues.executionRunId,
|
||||
assigneeAgentId: issues.assigneeAgentId,
|
||||
assigneeUserId: issues.assigneeUserId,
|
||||
})
|
||||
.from(issues)
|
||||
.where(
|
||||
and(
|
||||
eq(issues.companyId, companyId),
|
||||
inArray(issues.parentId, chunk),
|
||||
ne(issues.status, "done"),
|
||||
),
|
||||
);
|
||||
const [explicitBlockerRows, childRows] = await Promise.all([
|
||||
explicitBlockerRowsPromise,
|
||||
childRowsPromise,
|
||||
]);
|
||||
|
||||
appendBlockerAttentionEdges(edgesByIssueId, [
|
||||
...explicitBlockerRows
|
||||
.filter((row): row is IssueBlockerAttentionQueryRow & { issueId: string } => row.issueId !== null)
|
||||
.map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })),
|
||||
...childRows
|
||||
.filter((row): row is IssueBlockerAttentionQueryRow & { issueId: string } => row.issueId !== null)
|
||||
.map((row) => ({ issueId: row.issueId, blockerIssueId: row.blockerIssueId })),
|
||||
]);
|
||||
|
||||
for (const row of [...explicitBlockerRows, ...childRows]) {
|
||||
if (!row.issueId || nodesById.has(row.blockerIssueId)) continue;
|
||||
nodesById.set(row.blockerIssueId, {
|
||||
id: row.blockerIssueId,
|
||||
companyId: row.companyId,
|
||||
parentId: row.parentId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status,
|
||||
executionRunId: row.executionRunId,
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
});
|
||||
nextFrontier.add(row.blockerIssueId);
|
||||
}
|
||||
}
|
||||
|
||||
if (nodesById.size > BLOCKER_ATTENTION_MAX_NODES) {
|
||||
truncated = true;
|
||||
break;
|
||||
}
|
||||
frontier = [...nextFrontier];
|
||||
}
|
||||
if (frontier.length > 0) truncated = true;
|
||||
|
||||
const nodeIds = [...nodesById.keys()];
|
||||
const activeIssueIds = new Set<string>();
|
||||
const agentIds = new Set<string>();
|
||||
const issueIdByExecutionRunId = new Map<string, string>();
|
||||
for (const node of nodesById.values()) {
|
||||
if (node.assigneeAgentId) agentIds.add(node.assigneeAgentId);
|
||||
if (node.executionRunId) issueIdByExecutionRunId.set(node.executionRunId, node.id);
|
||||
}
|
||||
|
||||
for (const chunk of chunkList([...issueIdByExecutionRunId.keys()], ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const runRows: Array<{ id: string }> = await dbOrTx
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRuns.companyId, companyId),
|
||||
inArray(heartbeatRuns.status, BLOCKER_ATTENTION_ACTIVE_RUN_STATUSES),
|
||||
inArray(heartbeatRuns.id, chunk),
|
||||
),
|
||||
);
|
||||
|
||||
for (const row of runRows) {
|
||||
const issueId = issueIdByExecutionRunId.get(row.id);
|
||||
if (issueId) activeIssueIds.add(issueId);
|
||||
}
|
||||
}
|
||||
|
||||
for (const chunk of chunkList(nodeIds, ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE)) {
|
||||
const wakeRowsPromise: Promise<IssueBlockerAttentionActivePathRow[]> = dbOrTx
|
||||
.select({
|
||||
issueId: sql<string | null>`${agentWakeupRequests.payload} ->> 'issueId'`,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, companyId),
|
||||
inArray(agentWakeupRequests.status, BLOCKER_ATTENTION_ACTIVE_WAKE_STATUSES),
|
||||
sql`${agentWakeupRequests.runId} is null`,
|
||||
inArray(sql<string>`${agentWakeupRequests.payload} ->> 'issueId'`, chunk),
|
||||
),
|
||||
);
|
||||
const wakeRows = await wakeRowsPromise;
|
||||
for (const row of wakeRows) {
|
||||
if (row.issueId) activeIssueIds.add(row.issueId);
|
||||
}
|
||||
}
|
||||
|
||||
const agentRows: IssueBlockerAttentionAgentRow[] = agentIds.size > 0
|
||||
? await dbOrTx
|
||||
.select({
|
||||
id: agents.id,
|
||||
companyId: agents.companyId,
|
||||
status: agents.status,
|
||||
})
|
||||
.from(agents)
|
||||
.where(and(eq(agents.companyId, companyId), inArray(agents.id, [...agentIds])))
|
||||
: [];
|
||||
const agentsById = new Map(agentRows.map((agent) => [agent.id, agent]));
|
||||
|
||||
type PathClassification = { covered: boolean; sampleBlockerIdentifier: string | null };
|
||||
const classifyPath = (
|
||||
nodeId: string,
|
||||
seen: Set<string>,
|
||||
): PathClassification => {
|
||||
if (truncated || seen.has(nodeId)) return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(nodesById.get(nodeId)) };
|
||||
const node = nodesById.get(nodeId);
|
||||
if (!node || node.companyId !== companyId) return { covered: false, sampleBlockerIdentifier: nodeId };
|
||||
if (node.status === "done") return { covered: true, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
|
||||
if (activeIssueIds.has(node.id)) return { covered: true, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
|
||||
if (node.status === "cancelled") return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
|
||||
|
||||
const downstream = (edgesByIssueId.get(node.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
|
||||
if (downstream.length > 0) {
|
||||
const nextSeen = new Set(seen);
|
||||
nextSeen.add(nodeId);
|
||||
const classified = downstream.map((edge) => classifyPath(edge.blockerIssueId, nextSeen));
|
||||
const attention = classified.find((result) => !result.covered);
|
||||
if (attention) return attention;
|
||||
return {
|
||||
covered: true,
|
||||
sampleBlockerIdentifier: classified[0]?.sampleBlockerIdentifier ?? blockerSampleIdentifier(node),
|
||||
};
|
||||
}
|
||||
|
||||
if (node.assigneeAgentId) {
|
||||
const assignee = agentsById.get(node.assigneeAgentId);
|
||||
if (!assignee || assignee.companyId !== companyId || !BLOCKER_ATTENTION_INVOKABLE_AGENT_STATUSES.has(assignee.status)) {
|
||||
return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
|
||||
}
|
||||
}
|
||||
|
||||
return { covered: false, sampleBlockerIdentifier: blockerSampleIdentifier(node) };
|
||||
};
|
||||
|
||||
for (const root of roots) {
|
||||
const topLevelEdges = (edgesByIssueId.get(root.id) ?? []).filter((edge) => nodesById.get(edge.blockerIssueId)?.status !== "done");
|
||||
if (topLevelEdges.length === 0) {
|
||||
attentionMap.set(root.id, createIssueBlockerAttention({
|
||||
state: "needs_attention",
|
||||
reason: "attention_required",
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
const classified = topLevelEdges.map((edge) => ({
|
||||
edge,
|
||||
result: classifyPath(edge.blockerIssueId, new Set([root.id])),
|
||||
}));
|
||||
const coveredBlockerCount = classified.filter((entry) => entry.result.covered).length;
|
||||
const attentionBlockerCount = classified.length - coveredBlockerCount;
|
||||
const attentionEntry = classified.find((entry) => !entry.result.covered);
|
||||
const sampleEntry = attentionEntry ?? classified[0] ?? null;
|
||||
const sampleNode = sampleEntry ? nodesById.get(sampleEntry.edge.blockerIssueId) : null;
|
||||
|
||||
attentionMap.set(root.id, createIssueBlockerAttention({
|
||||
state: attentionBlockerCount === 0 ? "covered" : "needs_attention",
|
||||
reason: attentionBlockerCount === 0
|
||||
? topLevelEdges.every((edge) => nodesById.get(edge.blockerIssueId)?.parentId === root.id)
|
||||
? "active_child"
|
||||
: "active_dependency"
|
||||
: "attention_required",
|
||||
unresolvedBlockerCount: topLevelEdges.length,
|
||||
coveredBlockerCount,
|
||||
attentionBlockerCount,
|
||||
sampleBlockerIdentifier: sampleEntry?.result.sampleBlockerIdentifier ?? blockerSampleIdentifier(sampleNode),
|
||||
}));
|
||||
}
|
||||
|
||||
return attentionMap;
|
||||
}
|
||||
|
||||
const issueListSelect = {
|
||||
id: issues.id,
|
||||
companyId: issues.companyId,
|
||||
|
|
@ -956,18 +1359,25 @@ export function issueService(db: Db) {
|
|||
) {
|
||||
if (!checkoutRunId) return false;
|
||||
const run = await db
|
||||
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
||||
.select({
|
||||
id: heartbeatRuns.id,
|
||||
agentId: heartbeatRuns.agentId,
|
||||
wakeupRequestId: heartbeatRuns.wakeupRequestId,
|
||||
contextSnapshot: heartbeatRuns.contextSnapshot,
|
||||
})
|
||||
.from(heartbeatRuns)
|
||||
.where(and(eq(heartbeatRuns.id, checkoutRunId), eq(heartbeatRuns.companyId, companyId)))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
const wakeReason =
|
||||
readStringFromRecord(run?.contextSnapshot, "wakeReason") ??
|
||||
readStringFromRecord(run?.contextSnapshot, "reason");
|
||||
return Boolean(
|
||||
wakeReason &&
|
||||
ISSUE_TREE_CONTROL_INTERACTION_WAKE_REASONS.has(wakeReason) &&
|
||||
readLatestWakeCommentId(run?.contextSnapshot),
|
||||
);
|
||||
const issueId = readStringFromRecord(run?.contextSnapshot, "issueId");
|
||||
if (!run || !issueId) return false;
|
||||
return isVerifiedIssueTreeControlInteractionWake(db, {
|
||||
companyId,
|
||||
issueId,
|
||||
agentId: run.agentId,
|
||||
runId: run.id,
|
||||
wakeupRequestId: run.wakeupRequestId,
|
||||
contextSnapshot: run.contextSnapshot as Record<string, unknown> | null | undefined,
|
||||
});
|
||||
}
|
||||
|
||||
async function assertAssignableUser(companyId: string, userId: string) {
|
||||
|
|
@ -1118,30 +1528,26 @@ export function issueService(db: Db) {
|
|||
]);
|
||||
|
||||
for (const row of blockedByRows) {
|
||||
empty.get(row.currentIssueId)?.blockedBy.push({
|
||||
id: row.relatedId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status as IssueRelationIssueSummary["status"],
|
||||
priority: row.priority as IssueRelationIssueSummary["priority"],
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
});
|
||||
empty.get(row.currentIssueId)?.blockedBy.push(summarizeIssueRelationRow(row));
|
||||
}
|
||||
for (const row of blockingRows) {
|
||||
empty.get(row.currentIssueId)?.blocks.push({
|
||||
id: row.relatedId,
|
||||
identifier: row.identifier,
|
||||
title: row.title,
|
||||
status: row.status as IssueRelationIssueSummary["status"],
|
||||
priority: row.priority as IssueRelationIssueSummary["priority"],
|
||||
assigneeAgentId: row.assigneeAgentId,
|
||||
assigneeUserId: row.assigneeUserId,
|
||||
});
|
||||
empty.get(row.currentIssueId)?.blocks.push(summarizeIssueRelationRow(row));
|
||||
}
|
||||
|
||||
const terminalByRoot = await terminalExplicitBlockersByRoot(
|
||||
companyId,
|
||||
[...empty.values()].flatMap((relations) => relations.blockedBy),
|
||||
dbOrTx,
|
||||
);
|
||||
|
||||
for (const relations of empty.values()) {
|
||||
relations.blockedBy.sort((a, b) => a.title.localeCompare(b.title));
|
||||
for (const blocker of relations.blockedBy) {
|
||||
const terminalBlockers = terminalByRoot.get(blocker.id);
|
||||
if (terminalBlockers && terminalBlockers.length > 0) {
|
||||
blocker.terminalBlockers = terminalBlockers;
|
||||
}
|
||||
}
|
||||
relations.blocks.sort((a, b) => a.title.localeCompare(b.title));
|
||||
}
|
||||
|
||||
|
|
@ -1519,6 +1925,7 @@ export function issueService(db: Db) {
|
|||
]);
|
||||
const statsByIssueId = new Map(statsRows.map((row) => [row.issueId, row]));
|
||||
const lastActivityByIssueId = new Map(lastActivityRows.map((row) => [row.issueId, row]));
|
||||
const blockerAttentionByIssueId = await listIssueBlockerAttentionMap(db, companyId, withRuns);
|
||||
|
||||
if (!contextUserId) {
|
||||
return withRuns.map((row) => {
|
||||
|
|
@ -1531,6 +1938,7 @@ export function issueService(db: Db) {
|
|||
return {
|
||||
...row,
|
||||
lastActivityAt,
|
||||
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -1547,6 +1955,7 @@ export function issueService(db: Db) {
|
|||
return {
|
||||
...row,
|
||||
lastActivityAt,
|
||||
...(blockerAttentionByIssueId.has(row.id) ? { blockerAttention: blockerAttentionByIssueId.get(row.id) } : {}),
|
||||
...deriveIssueUserContext(row, contextUserId, {
|
||||
myLastCommentAt: statsByIssueId.get(row.id)?.myLastCommentAt ?? null,
|
||||
myLastReadAt: readByIssueId.get(row.id) ?? null,
|
||||
|
|
@ -1690,6 +2099,14 @@ export function issueService(db: Db) {
|
|||
return listIssueDependencyReadinessMap(dbOrTx, companyId, issueIds);
|
||||
},
|
||||
|
||||
listBlockerAttention: async (
|
||||
companyId: string,
|
||||
issueRows: IssueBlockerAttentionInputNode[],
|
||||
dbOrTx: any = db,
|
||||
) => {
|
||||
return listIssueBlockerAttentionMap(dbOrTx, companyId, issueRows);
|
||||
},
|
||||
|
||||
listWakeableBlockedDependents: async (blockerIssueId: string) => {
|
||||
const blockerIssue = await db
|
||||
.select({ id: issues.id, companyId: issues.companyId })
|
||||
|
|
|
|||
43
server/src/services/recovery/index.ts
Normal file
43
server/src/services/recovery/index.ts
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
export {
|
||||
RECOVERY_KEY_PREFIXES,
|
||||
RECOVERY_ORIGIN_KINDS,
|
||||
RECOVERY_REASON_KINDS,
|
||||
buildIssueGraphLivenessIncidentKey,
|
||||
buildIssueGraphLivenessLeafKey,
|
||||
parseIssueGraphLivenessIncidentKey,
|
||||
} from "./origins.js";
|
||||
export type {
|
||||
RecoveryKeyPrefix,
|
||||
RecoveryOriginKind,
|
||||
RecoveryReasonKind,
|
||||
} from "./origins.js";
|
||||
export {
|
||||
classifyIssueGraphLiveness,
|
||||
} from "./issue-graph-liveness.js";
|
||||
export type {
|
||||
IssueGraphLivenessInput,
|
||||
IssueLivenessAgentInput,
|
||||
IssueLivenessDependencyPathEntry,
|
||||
IssueLivenessExecutionPathInput,
|
||||
IssueLivenessFinding,
|
||||
IssueLivenessIssueInput,
|
||||
IssueLivenessOwnerCandidate,
|
||||
IssueLivenessOwnerCandidateReason,
|
||||
IssueLivenessRelationInput,
|
||||
IssueLivenessSeverity,
|
||||
IssueLivenessState,
|
||||
} from "./issue-graph-liveness.js";
|
||||
export {
|
||||
recoveryService,
|
||||
} from "./service.js";
|
||||
export {
|
||||
DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
|
||||
RUN_LIVENESS_CONTINUATION_REASON,
|
||||
buildRunLivenessContinuationIdempotencyKey,
|
||||
decideRunLivenessContinuation,
|
||||
findExistingRunLivenessContinuationWake,
|
||||
readContinuationAttempt,
|
||||
} from "./run-liveness-continuations.js";
|
||||
export type {
|
||||
RunContinuationDecision,
|
||||
} from "./run-liveness-continuations.js";
|
||||
414
server/src/services/recovery/issue-graph-liveness.ts
Normal file
414
server/src/services/recovery/issue-graph-liveness.ts
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
import { buildIssueGraphLivenessIncidentKey } from "./origins.js";
|
||||
|
||||
export type IssueLivenessSeverity = "warning" | "critical";
|
||||
|
||||
export type IssueLivenessState =
|
||||
| "blocked_by_unassigned_issue"
|
||||
| "blocked_by_uninvokable_assignee"
|
||||
| "blocked_by_cancelled_issue"
|
||||
| "invalid_review_participant";
|
||||
|
||||
export interface IssueLivenessIssueInput {
|
||||
id: string;
|
||||
companyId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
projectId?: string | null;
|
||||
goalId?: string | null;
|
||||
parentId?: string | null;
|
||||
assigneeAgentId?: string | null;
|
||||
assigneeUserId?: string | null;
|
||||
createdByAgentId?: string | null;
|
||||
createdByUserId?: string | null;
|
||||
executionState?: Record<string, unknown> | null;
|
||||
}
|
||||
|
||||
export interface IssueLivenessRelationInput {
|
||||
companyId: string;
|
||||
blockerIssueId: string;
|
||||
blockedIssueId: string;
|
||||
}
|
||||
|
||||
export interface IssueLivenessAgentInput {
|
||||
id: string;
|
||||
companyId: string;
|
||||
name: string;
|
||||
role: string;
|
||||
title?: string | null;
|
||||
status: string;
|
||||
reportsTo?: string | null;
|
||||
}
|
||||
|
||||
export interface IssueLivenessExecutionPathInput {
|
||||
companyId: string;
|
||||
issueId: string | null;
|
||||
agentId?: string | null;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface IssueLivenessDependencyPathEntry {
|
||||
issueId: string;
|
||||
identifier: string | null;
|
||||
title: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export type IssueLivenessOwnerCandidateReason =
|
||||
| "stalled_blocker_assignee"
|
||||
| "assignee_reporting_chain"
|
||||
| "creator_reporting_chain"
|
||||
| "root_agent"
|
||||
| "ordered_invokable_fallback";
|
||||
|
||||
export interface IssueLivenessOwnerCandidate {
|
||||
agentId: string;
|
||||
reason: IssueLivenessOwnerCandidateReason;
|
||||
sourceIssueId: string;
|
||||
}
|
||||
|
||||
export interface IssueLivenessFinding {
|
||||
issueId: string;
|
||||
companyId: string;
|
||||
identifier: string | null;
|
||||
state: IssueLivenessState;
|
||||
severity: IssueLivenessSeverity;
|
||||
reason: string;
|
||||
dependencyPath: IssueLivenessDependencyPathEntry[];
|
||||
recoveryIssueId: string;
|
||||
recommendedOwnerAgentId: string | null;
|
||||
recommendedOwnerCandidateAgentIds: string[];
|
||||
recommendedOwnerCandidates: IssueLivenessOwnerCandidate[];
|
||||
recommendedAction: string;
|
||||
incidentKey: string;
|
||||
}
|
||||
|
||||
export interface IssueGraphLivenessInput {
|
||||
issues: IssueLivenessIssueInput[];
|
||||
relations: IssueLivenessRelationInput[];
|
||||
agents: IssueLivenessAgentInput[];
|
||||
activeRuns?: IssueLivenessExecutionPathInput[];
|
||||
queuedWakeRequests?: IssueLivenessExecutionPathInput[];
|
||||
}
|
||||
|
||||
const INVOKABLE_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
const BLOCKING_AGENT_STATUSES = new Set(["paused", "terminated", "pending_approval"]);
|
||||
|
||||
function issueLabel(issue: IssueLivenessIssueInput) {
|
||||
return issue.identifier ?? issue.id;
|
||||
}
|
||||
|
||||
function pathEntry(issue: IssueLivenessIssueInput): IssueLivenessDependencyPathEntry {
|
||||
return {
|
||||
issueId: issue.id,
|
||||
identifier: issue.identifier,
|
||||
title: issue.title,
|
||||
status: issue.status,
|
||||
};
|
||||
}
|
||||
|
||||
function isInvokableAgent(agent: IssueLivenessAgentInput | null | undefined) {
|
||||
return Boolean(agent && INVOKABLE_AGENT_STATUSES.has(agent.status));
|
||||
}
|
||||
|
||||
function hasActiveExecutionPath(
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
activeRuns: IssueLivenessExecutionPathInput[],
|
||||
queuedWakeRequests: IssueLivenessExecutionPathInput[],
|
||||
) {
|
||||
return [...activeRuns, ...queuedWakeRequests].some(
|
||||
(entry) => entry.companyId === companyId && entry.issueId === issueId,
|
||||
);
|
||||
}
|
||||
|
||||
function readPrincipalAgentId(principal: unknown): string | null {
|
||||
if (!principal || typeof principal !== "object") return null;
|
||||
const value = principal as Record<string, unknown>;
|
||||
return value.type === "agent" && typeof value.agentId === "string" && value.agentId.length > 0
|
||||
? value.agentId
|
||||
: null;
|
||||
}
|
||||
|
||||
function principalIsResolvableUser(principal: unknown): boolean {
|
||||
if (!principal || typeof principal !== "object") return false;
|
||||
const value = principal as Record<string, unknown>;
|
||||
return value.type === "user" && typeof value.userId === "string" && value.userId.length > 0;
|
||||
}
|
||||
|
||||
function addOwnerCandidate(
|
||||
candidates: IssueLivenessOwnerCandidate[],
|
||||
seen: Set<string>,
|
||||
agentsById: Map<string, IssueLivenessAgentInput>,
|
||||
companyId: string,
|
||||
agentId: string | null | undefined,
|
||||
reason: IssueLivenessOwnerCandidateReason,
|
||||
sourceIssueId: string,
|
||||
) {
|
||||
if (!agentId || seen.has(agentId)) return;
|
||||
const agent = agentsById.get(agentId);
|
||||
if (!agent || agent.companyId !== companyId || !isInvokableAgent(agent)) return;
|
||||
seen.add(agentId);
|
||||
candidates.push({ agentId, reason, sourceIssueId });
|
||||
}
|
||||
|
||||
function addAgentChainCandidates(
|
||||
candidates: IssueLivenessOwnerCandidate[],
|
||||
seen: Set<string>,
|
||||
startAgentId: string | null | undefined,
|
||||
agentsById: Map<string, IssueLivenessAgentInput>,
|
||||
companyId: string,
|
||||
reason: IssueLivenessOwnerCandidateReason,
|
||||
sourceIssueId: string,
|
||||
) {
|
||||
const chainSeen = new Set<string>();
|
||||
let current = startAgentId ? agentsById.get(startAgentId) : null;
|
||||
|
||||
while (current?.reportsTo) {
|
||||
if (chainSeen.has(current.reportsTo)) break;
|
||||
chainSeen.add(current.reportsTo);
|
||||
const manager = agentsById.get(current.reportsTo);
|
||||
if (!manager || manager.companyId !== companyId) break;
|
||||
addOwnerCandidate(candidates, seen, agentsById, companyId, manager.id, reason, sourceIssueId);
|
||||
current = manager;
|
||||
}
|
||||
}
|
||||
|
||||
function orderedInvokableAgents(agents: IssueLivenessAgentInput[], companyId: string) {
|
||||
return agents
|
||||
.filter((agent) => agent.companyId === companyId && isInvokableAgent(agent))
|
||||
.sort((left, right) => left.id.localeCompare(right.id));
|
||||
}
|
||||
|
||||
function ownerCandidatesForRecoveryIssue(
|
||||
issue: IssueLivenessIssueInput,
|
||||
agents: IssueLivenessAgentInput[],
|
||||
agentsById: Map<string, IssueLivenessAgentInput>,
|
||||
options: {
|
||||
includeStalledAssignee?: boolean;
|
||||
} = {},
|
||||
) {
|
||||
const candidates: IssueLivenessOwnerCandidate[] = [];
|
||||
const seen = new Set<string>();
|
||||
|
||||
if (options.includeStalledAssignee && issue.status !== "cancelled" && issue.status !== "done") {
|
||||
addOwnerCandidate(
|
||||
candidates,
|
||||
seen,
|
||||
agentsById,
|
||||
issue.companyId,
|
||||
issue.assigneeAgentId,
|
||||
"stalled_blocker_assignee",
|
||||
issue.id,
|
||||
);
|
||||
}
|
||||
|
||||
addAgentChainCandidates(
|
||||
candidates,
|
||||
seen,
|
||||
issue.assigneeAgentId,
|
||||
agentsById,
|
||||
issue.companyId,
|
||||
"assignee_reporting_chain",
|
||||
issue.id,
|
||||
);
|
||||
addAgentChainCandidates(
|
||||
candidates,
|
||||
seen,
|
||||
issue.createdByAgentId,
|
||||
agentsById,
|
||||
issue.companyId,
|
||||
"creator_reporting_chain",
|
||||
issue.id,
|
||||
);
|
||||
|
||||
const invokableAgents = orderedInvokableAgents(agents, issue.companyId);
|
||||
for (const agent of invokableAgents) {
|
||||
if (!agent.reportsTo) {
|
||||
addOwnerCandidate(candidates, seen, agentsById, issue.companyId, agent.id, "root_agent", issue.id);
|
||||
}
|
||||
}
|
||||
for (const agent of invokableAgents) {
|
||||
addOwnerCandidate(
|
||||
candidates,
|
||||
seen,
|
||||
agentsById,
|
||||
issue.companyId,
|
||||
agent.id,
|
||||
"ordered_invokable_fallback",
|
||||
issue.id,
|
||||
);
|
||||
}
|
||||
|
||||
return candidates;
|
||||
}
|
||||
|
||||
function incidentKey(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
state: IssueLivenessState;
|
||||
blockerIssueId?: string | null;
|
||||
participantAgentId?: string | null;
|
||||
}) {
|
||||
return buildIssueGraphLivenessIncidentKey(input);
|
||||
}
|
||||
|
||||
function finding(input: {
|
||||
issue: IssueLivenessIssueInput;
|
||||
state: IssueLivenessState;
|
||||
severity?: IssueLivenessSeverity;
|
||||
reason: string;
|
||||
dependencyPath: IssueLivenessIssueInput[];
|
||||
recoveryIssue: IssueLivenessIssueInput;
|
||||
recommendedOwnerCandidateAgentIds: string[];
|
||||
recommendedOwnerCandidates: IssueLivenessOwnerCandidate[];
|
||||
recommendedAction: string;
|
||||
blockerIssueId?: string | null;
|
||||
participantAgentId?: string | null;
|
||||
}): IssueLivenessFinding {
|
||||
return {
|
||||
issueId: input.issue.id,
|
||||
companyId: input.issue.companyId,
|
||||
identifier: input.issue.identifier,
|
||||
state: input.state,
|
||||
severity: input.severity ?? "critical",
|
||||
reason: input.reason,
|
||||
dependencyPath: input.dependencyPath.map(pathEntry),
|
||||
recoveryIssueId: input.recoveryIssue.id,
|
||||
recommendedOwnerAgentId: input.recommendedOwnerCandidateAgentIds[0] ?? null,
|
||||
recommendedOwnerCandidateAgentIds: input.recommendedOwnerCandidateAgentIds,
|
||||
recommendedOwnerCandidates: input.recommendedOwnerCandidates,
|
||||
recommendedAction: input.recommendedAction,
|
||||
incidentKey: incidentKey({
|
||||
companyId: input.issue.companyId,
|
||||
issueId: input.issue.id,
|
||||
state: input.state,
|
||||
blockerIssueId: input.blockerIssueId,
|
||||
participantAgentId: input.participantAgentId,
|
||||
}),
|
||||
};
|
||||
}
|
||||
|
||||
export function classifyIssueGraphLiveness(input: IssueGraphLivenessInput): IssueLivenessFinding[] {
|
||||
const issuesById = new Map(input.issues.map((issue) => [issue.id, issue]));
|
||||
const agentsById = new Map(input.agents.map((agent) => [agent.id, agent]));
|
||||
const blockersByBlockedIssueId = new Map<string, IssueLivenessRelationInput[]>();
|
||||
const findings: IssueLivenessFinding[] = [];
|
||||
const activeRuns = input.activeRuns ?? [];
|
||||
const queuedWakeRequests = input.queuedWakeRequests ?? [];
|
||||
|
||||
for (const relation of input.relations) {
|
||||
const list = blockersByBlockedIssueId.get(relation.blockedIssueId) ?? [];
|
||||
list.push(relation);
|
||||
blockersByBlockedIssueId.set(relation.blockedIssueId, list);
|
||||
}
|
||||
|
||||
for (const issue of input.issues) {
|
||||
if (issue.status === "blocked") {
|
||||
const relations = blockersByBlockedIssueId.get(issue.id) ?? [];
|
||||
for (const relation of relations) {
|
||||
if (relation.companyId !== issue.companyId) continue;
|
||||
const blocker = issuesById.get(relation.blockerIssueId);
|
||||
if (!blocker || blocker.companyId !== issue.companyId || blocker.status === "done") continue;
|
||||
const ownerCandidates = ownerCandidatesForRecoveryIssue(blocker, input.agents, agentsById, {
|
||||
includeStalledAssignee: true,
|
||||
});
|
||||
|
||||
if (blocker.status === "cancelled") {
|
||||
findings.push(finding({
|
||||
issue,
|
||||
state: "blocked_by_cancelled_issue",
|
||||
reason: `${issueLabel(issue)} is still blocked by cancelled issue ${issueLabel(blocker)}.`,
|
||||
dependencyPath: [issue, blocker],
|
||||
recoveryIssue: blocker,
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
|
||||
recommendedOwnerCandidates: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Inspect ${issueLabel(blocker)} and either remove it from ${issueLabel(issue)}'s blockers or replace it with an actionable unblock issue.`,
|
||||
blockerIssueId: blocker.id,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!blocker.assigneeAgentId && !blocker.assigneeUserId) {
|
||||
if (hasActiveExecutionPath(issue.companyId, blocker.id, activeRuns, queuedWakeRequests)) continue;
|
||||
findings.push(finding({
|
||||
issue,
|
||||
state: "blocked_by_unassigned_issue",
|
||||
reason: `${issueLabel(issue)} is blocked by unassigned issue ${issueLabel(blocker)} with no user owner.`,
|
||||
dependencyPath: [issue, blocker],
|
||||
recoveryIssue: blocker,
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
|
||||
recommendedOwnerCandidates: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Assign ${issueLabel(blocker)} to an owner who can complete it, or remove it from ${issueLabel(issue)}'s blockers if it is no longer required.`,
|
||||
blockerIssueId: blocker.id,
|
||||
}));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!blocker.assigneeAgentId) continue;
|
||||
if (hasActiveExecutionPath(issue.companyId, blocker.id, activeRuns, queuedWakeRequests)) continue;
|
||||
|
||||
const blockerAgent = agentsById.get(blocker.assigneeAgentId);
|
||||
if (!blockerAgent || blockerAgent.companyId !== issue.companyId || BLOCKING_AGENT_STATUSES.has(blockerAgent.status)) {
|
||||
findings.push(finding({
|
||||
issue,
|
||||
state: "blocked_by_uninvokable_assignee",
|
||||
reason: blockerAgent
|
||||
? `${issueLabel(issue)} is blocked by ${issueLabel(blocker)}, but its assignee is ${blockerAgent.status}.`
|
||||
: `${issueLabel(issue)} is blocked by ${issueLabel(blocker)}, but its assignee no longer exists.`,
|
||||
dependencyPath: [issue, blocker],
|
||||
recoveryIssue: blocker,
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
|
||||
recommendedOwnerCandidates: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Review ${issueLabel(blocker)} and assign it to an active owner or replace the blocker with an actionable issue.`,
|
||||
blockerIssueId: blocker.id,
|
||||
}));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (issue.status !== "in_review" || !issue.executionState) continue;
|
||||
const ownerCandidates = ownerCandidatesForRecoveryIssue(issue, input.agents, agentsById);
|
||||
const participant = issue.executionState.currentParticipant;
|
||||
const participantAgentId = readPrincipalAgentId(participant);
|
||||
if (participantAgentId) {
|
||||
const participantAgent = agentsById.get(participantAgentId);
|
||||
if (!isInvokableAgent(participantAgent) || participantAgent?.companyId !== issue.companyId) {
|
||||
findings.push(finding({
|
||||
issue,
|
||||
state: "invalid_review_participant",
|
||||
reason: participantAgent
|
||||
? `${issueLabel(issue)} is in review, but current participant agent is ${participantAgent.status}.`
|
||||
: `${issueLabel(issue)} is in review, but current participant agent cannot be resolved.`,
|
||||
dependencyPath: [issue],
|
||||
recoveryIssue: issue,
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
|
||||
recommendedOwnerCandidates: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Repair ${issueLabel(issue)}'s review participant or return the issue to an active assignee with a clear change request.`,
|
||||
participantAgentId,
|
||||
}));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!principalIsResolvableUser(participant)) {
|
||||
findings.push(finding({
|
||||
issue,
|
||||
state: "invalid_review_participant",
|
||||
reason: `${issueLabel(issue)} is in review, but its current participant cannot be resolved.`,
|
||||
dependencyPath: [issue],
|
||||
recoveryIssue: issue,
|
||||
recommendedOwnerCandidateAgentIds: ownerCandidates.map((candidate) => candidate.agentId),
|
||||
recommendedOwnerCandidates: ownerCandidates,
|
||||
recommendedAction:
|
||||
`Repair ${issueLabel(issue)}'s review participant or return the issue to an active assignee with a clear change request.`,
|
||||
}));
|
||||
}
|
||||
}
|
||||
|
||||
return findings;
|
||||
}
|
||||
56
server/src/services/recovery/origins.ts
Normal file
56
server/src/services/recovery/origins.ts
Normal file
|
|
@ -0,0 +1,56 @@
|
|||
export const RECOVERY_ORIGIN_KINDS = {
|
||||
issueGraphLivenessEscalation: "harness_liveness_escalation",
|
||||
strandedIssueRecovery: "stranded_issue_recovery",
|
||||
staleActiveRunEvaluation: "stale_active_run_evaluation",
|
||||
} as const;
|
||||
|
||||
export const RECOVERY_REASON_KINDS = {
|
||||
runLivenessContinuation: "run_liveness_continuation",
|
||||
} as const;
|
||||
|
||||
export const RECOVERY_KEY_PREFIXES = {
|
||||
issueGraphLivenessIncident: "harness_liveness",
|
||||
issueGraphLivenessLeaf: "harness_liveness_leaf",
|
||||
} as const;
|
||||
|
||||
export type RecoveryOriginKind = typeof RECOVERY_ORIGIN_KINDS[keyof typeof RECOVERY_ORIGIN_KINDS];
|
||||
export type RecoveryReasonKind = typeof RECOVERY_REASON_KINDS[keyof typeof RECOVERY_REASON_KINDS];
|
||||
export type RecoveryKeyPrefix = typeof RECOVERY_KEY_PREFIXES[keyof typeof RECOVERY_KEY_PREFIXES];
|
||||
|
||||
export function buildIssueGraphLivenessIncidentKey(input: {
|
||||
companyId: string;
|
||||
issueId: string;
|
||||
state: string;
|
||||
blockerIssueId?: string | null;
|
||||
participantAgentId?: string | null;
|
||||
}) {
|
||||
return [
|
||||
RECOVERY_KEY_PREFIXES.issueGraphLivenessIncident,
|
||||
input.companyId,
|
||||
input.issueId,
|
||||
input.state,
|
||||
input.blockerIssueId ?? input.participantAgentId ?? "none",
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export function parseIssueGraphLivenessIncidentKey(incidentKey: string | null | undefined) {
|
||||
if (!incidentKey) return null;
|
||||
const parts = incidentKey.split(":");
|
||||
if (parts.length !== 5 || parts[0] !== RECOVERY_KEY_PREFIXES.issueGraphLivenessIncident) return null;
|
||||
const [, companyId, issueId, state, leafIssueId] = parts;
|
||||
if (!companyId || !issueId || !state || !leafIssueId) return null;
|
||||
return { companyId, issueId, state, leafIssueId };
|
||||
}
|
||||
|
||||
export function buildIssueGraphLivenessLeafKey(input: {
|
||||
companyId: string;
|
||||
state: string;
|
||||
leafIssueId: string;
|
||||
}) {
|
||||
return [
|
||||
RECOVERY_KEY_PREFIXES.issueGraphLivenessLeaf,
|
||||
input.companyId,
|
||||
input.state,
|
||||
input.leafIssueId,
|
||||
].join(":");
|
||||
}
|
||||
14
server/src/services/recovery/pause-hold-guard.ts
Normal file
14
server/src/services/recovery/pause-hold-guard.ts
Normal file
|
|
@ -0,0 +1,14 @@
|
|||
import type { Db } from "@paperclipai/db";
|
||||
import { issueTreeControlService } from "../issue-tree-control.js";
|
||||
|
||||
type IssueTreeControlService = ReturnType<typeof issueTreeControlService>;
|
||||
|
||||
export async function isAutomaticRecoverySuppressedByPauseHold(
|
||||
db: Db,
|
||||
companyId: string,
|
||||
issueId: string,
|
||||
treeControlSvc: IssueTreeControlService = issueTreeControlService(db),
|
||||
) {
|
||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(companyId, issueId);
|
||||
return Boolean(activePauseHold);
|
||||
}
|
||||
189
server/src/services/recovery/run-liveness-continuations.ts
Normal file
189
server/src/services/recovery/run-liveness-continuations.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import type { RunLivenessState } from "@paperclipai/shared";
|
||||
import { RECOVERY_REASON_KINDS } from "./origins.js";
|
||||
|
||||
export const RUN_LIVENESS_CONTINUATION_REASON = RECOVERY_REASON_KINDS.runLivenessContinuation;
|
||||
export const DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS = 2;
|
||||
|
||||
const ACTIONABLE_LIVENESS_STATES = new Set<RunLivenessState>(["plan_only", "empty_response"]);
|
||||
const CONTINUATION_ACTIVE_ISSUE_STATUSES = new Set(["todo", "in_progress"]);
|
||||
// A prior adapter error should not permanently suppress bounded liveness
|
||||
// continuations; the max-attempt/idempotency guards prevent unbounded retries.
|
||||
const CONTINUATION_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
const IDEMPOTENT_WAKE_STATUSES = ["queued", "deferred_issue_execution", "completed"];
|
||||
|
||||
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
|
||||
type IssueRow = Pick<
|
||||
typeof issues.$inferSelect,
|
||||
"id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "executionState" | "projectId"
|
||||
>;
|
||||
type AgentRow = Pick<typeof agents.$inferSelect, "id" | "companyId" | "status">;
|
||||
|
||||
export type RunContinuationDecision =
|
||||
| {
|
||||
kind: "enqueue";
|
||||
nextAttempt: number;
|
||||
idempotencyKey: string;
|
||||
payload: Record<string, unknown>;
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
kind: "exhausted";
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
comment: string;
|
||||
}
|
||||
| {
|
||||
kind: "skip";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function readContinuationAttempt(value: unknown): number {
|
||||
const numeric = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0;
|
||||
}
|
||||
|
||||
export function buildRunLivenessContinuationIdempotencyKey(input: {
|
||||
issueId: string;
|
||||
sourceRunId: string;
|
||||
livenessState: RunLivenessState;
|
||||
nextAttempt: number;
|
||||
}) {
|
||||
return [
|
||||
RUN_LIVENESS_CONTINUATION_REASON,
|
||||
input.issueId,
|
||||
input.sourceRunId,
|
||||
input.livenessState,
|
||||
String(input.nextAttempt),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export async function findExistingRunLivenessContinuationWake(
|
||||
db: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
idempotencyKey: string;
|
||||
},
|
||||
) {
|
||||
return db
|
||||
.select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, input.companyId),
|
||||
eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey),
|
||||
inArray(agentWakeupRequests.status, IDEMPOTENT_WAKE_STATUSES),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
export function decideRunLivenessContinuation(input: {
|
||||
run: HeartbeatRunRow;
|
||||
issue: IssueRow | null;
|
||||
agent: AgentRow | null;
|
||||
livenessState: RunLivenessState | null;
|
||||
livenessReason: string | null;
|
||||
nextAction: string | null;
|
||||
budgetBlocked: boolean;
|
||||
idempotentWakeExists: boolean;
|
||||
maxAttempts?: number;
|
||||
}): RunContinuationDecision {
|
||||
const {
|
||||
run,
|
||||
issue,
|
||||
agent,
|
||||
livenessState,
|
||||
livenessReason,
|
||||
nextAction,
|
||||
budgetBlocked,
|
||||
idempotentWakeExists,
|
||||
} = input;
|
||||
const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS;
|
||||
|
||||
if (!livenessState || !ACTIONABLE_LIVENESS_STATES.has(livenessState)) {
|
||||
return { kind: "skip", reason: "liveness state is not actionable for continuation" };
|
||||
}
|
||||
if (!issue) return { kind: "skip", reason: "issue not found" };
|
||||
if (!agent) return { kind: "skip", reason: "agent not found" };
|
||||
if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) {
|
||||
return { kind: "skip", reason: "company scope mismatch" };
|
||||
}
|
||||
if (issue.assigneeAgentId !== run.agentId) {
|
||||
return { kind: "skip", reason: "issue is no longer assigned to the source run agent" };
|
||||
}
|
||||
if (!CONTINUATION_ACTIVE_ISSUE_STATUSES.has(issue.status)) {
|
||||
return { kind: "skip", reason: `issue status ${issue.status} is not continuable` };
|
||||
}
|
||||
if (issue.executionState) {
|
||||
return { kind: "skip", reason: "issue is blocked by execution policy state" };
|
||||
}
|
||||
if (!CONTINUATION_AGENT_STATUSES.has(agent.status)) {
|
||||
return { kind: "skip", reason: `agent status ${agent.status} is not invokable` };
|
||||
}
|
||||
if (budgetBlocked) {
|
||||
return { kind: "skip", reason: "budget hard stop blocks continuation" };
|
||||
}
|
||||
|
||||
const currentAttempt = readContinuationAttempt(run.continuationAttempt);
|
||||
if (currentAttempt >= maxAttempts) {
|
||||
return {
|
||||
kind: "exhausted",
|
||||
attempt: currentAttempt,
|
||||
maxAttempts,
|
||||
comment: [
|
||||
"Bounded liveness continuation exhausted",
|
||||
"",
|
||||
`- Last liveness state: \`${livenessState}\``,
|
||||
`- Attempts used: ${currentAttempt}/${maxAttempts}`,
|
||||
`- Reason: ${livenessReason ?? "Run ended without concrete progress"}`,
|
||||
"- Next action: a human or manager should inspect the run and either clarify the task, mark it blocked, or assign a concrete follow-up.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
const nextAttempt = currentAttempt + 1;
|
||||
const idempotencyKey = buildRunLivenessContinuationIdempotencyKey({
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
livenessState,
|
||||
nextAttempt,
|
||||
});
|
||||
if (idempotentWakeExists) {
|
||||
return { kind: "skip", reason: "continuation wake already exists for this source run and attempt" };
|
||||
}
|
||||
|
||||
const payload = {
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
livenessState,
|
||||
livenessReason,
|
||||
continuationAttempt: nextAttempt,
|
||||
maxContinuationAttempts: maxAttempts,
|
||||
instruction:
|
||||
nextAction ??
|
||||
"The previous run ended without concrete progress. Take the first concrete action now or mark the issue blocked with a specific unblock request.",
|
||||
};
|
||||
|
||||
return {
|
||||
kind: "enqueue",
|
||||
nextAttempt,
|
||||
idempotencyKey,
|
||||
payload,
|
||||
contextSnapshot: {
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
taskKey: issue.id,
|
||||
wakeReason: RUN_LIVENESS_CONTINUATION_REASON,
|
||||
livenessContinuationAttempt: nextAttempt,
|
||||
livenessContinuationMaxAttempts: maxAttempts,
|
||||
livenessContinuationSourceRunId: run.id,
|
||||
livenessContinuationState: livenessState,
|
||||
livenessContinuationReason: livenessReason,
|
||||
livenessContinuationInstruction: payload.instruction,
|
||||
},
|
||||
};
|
||||
}
|
||||
2143
server/src/services/recovery/service.ts
Normal file
2143
server/src/services/recovery/service.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,188 +1,11 @@
|
|||
import { and, eq, inArray } from "drizzle-orm";
|
||||
import type { Db } from "@paperclipai/db";
|
||||
import { agentWakeupRequests, agents, heartbeatRuns, issues } from "@paperclipai/db";
|
||||
import type { RunLivenessState } from "@paperclipai/shared";
|
||||
|
||||
export const RUN_LIVENESS_CONTINUATION_REASON = "run_liveness_continuation";
|
||||
export const DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS = 2;
|
||||
|
||||
const ACTIONABLE_LIVENESS_STATES = new Set<RunLivenessState>(["plan_only", "empty_response"]);
|
||||
const CONTINUATION_ACTIVE_ISSUE_STATUSES = new Set(["todo", "in_progress"]);
|
||||
// A prior adapter error should not permanently suppress bounded liveness
|
||||
// continuations; the max-attempt/idempotency guards prevent unbounded retries.
|
||||
const CONTINUATION_AGENT_STATUSES = new Set(["active", "idle", "running", "error"]);
|
||||
const IDEMPOTENT_WAKE_STATUSES = ["queued", "deferred_issue_execution", "completed"];
|
||||
|
||||
type HeartbeatRunRow = typeof heartbeatRuns.$inferSelect;
|
||||
type IssueRow = Pick<
|
||||
typeof issues.$inferSelect,
|
||||
"id" | "companyId" | "identifier" | "title" | "status" | "assigneeAgentId" | "executionState" | "projectId"
|
||||
>;
|
||||
type AgentRow = Pick<typeof agents.$inferSelect, "id" | "companyId" | "status">;
|
||||
|
||||
export type RunContinuationDecision =
|
||||
| {
|
||||
kind: "enqueue";
|
||||
nextAttempt: number;
|
||||
idempotencyKey: string;
|
||||
payload: Record<string, unknown>;
|
||||
contextSnapshot: Record<string, unknown>;
|
||||
}
|
||||
| {
|
||||
kind: "exhausted";
|
||||
attempt: number;
|
||||
maxAttempts: number;
|
||||
comment: string;
|
||||
}
|
||||
| {
|
||||
kind: "skip";
|
||||
reason: string;
|
||||
};
|
||||
|
||||
export function readContinuationAttempt(value: unknown): number {
|
||||
const numeric = typeof value === "number" ? value : Number.parseInt(String(value ?? ""), 10);
|
||||
return Number.isFinite(numeric) && numeric > 0 ? Math.floor(numeric) : 0;
|
||||
}
|
||||
|
||||
export function buildRunLivenessContinuationIdempotencyKey(input: {
|
||||
issueId: string;
|
||||
sourceRunId: string;
|
||||
livenessState: RunLivenessState;
|
||||
nextAttempt: number;
|
||||
}) {
|
||||
return [
|
||||
"run_liveness_continuation",
|
||||
input.issueId,
|
||||
input.sourceRunId,
|
||||
input.livenessState,
|
||||
String(input.nextAttempt),
|
||||
].join(":");
|
||||
}
|
||||
|
||||
export async function findExistingRunLivenessContinuationWake(
|
||||
db: Db,
|
||||
input: {
|
||||
companyId: string;
|
||||
idempotencyKey: string;
|
||||
},
|
||||
) {
|
||||
return db
|
||||
.select({ id: agentWakeupRequests.id, status: agentWakeupRequests.status })
|
||||
.from(agentWakeupRequests)
|
||||
.where(
|
||||
and(
|
||||
eq(agentWakeupRequests.companyId, input.companyId),
|
||||
eq(agentWakeupRequests.idempotencyKey, input.idempotencyKey),
|
||||
inArray(agentWakeupRequests.status, IDEMPOTENT_WAKE_STATUSES),
|
||||
),
|
||||
)
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
}
|
||||
|
||||
export function decideRunLivenessContinuation(input: {
|
||||
run: HeartbeatRunRow;
|
||||
issue: IssueRow | null;
|
||||
agent: AgentRow | null;
|
||||
livenessState: RunLivenessState | null;
|
||||
livenessReason: string | null;
|
||||
nextAction: string | null;
|
||||
budgetBlocked: boolean;
|
||||
idempotentWakeExists: boolean;
|
||||
maxAttempts?: number;
|
||||
}): RunContinuationDecision {
|
||||
const {
|
||||
run,
|
||||
issue,
|
||||
agent,
|
||||
livenessState,
|
||||
livenessReason,
|
||||
nextAction,
|
||||
budgetBlocked,
|
||||
idempotentWakeExists,
|
||||
} = input;
|
||||
const maxAttempts = input.maxAttempts ?? DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS;
|
||||
|
||||
if (!livenessState || !ACTIONABLE_LIVENESS_STATES.has(livenessState)) {
|
||||
return { kind: "skip", reason: "liveness state is not actionable for continuation" };
|
||||
}
|
||||
if (!issue) return { kind: "skip", reason: "issue not found" };
|
||||
if (!agent) return { kind: "skip", reason: "agent not found" };
|
||||
if (issue.companyId !== run.companyId || agent.companyId !== run.companyId) {
|
||||
return { kind: "skip", reason: "company scope mismatch" };
|
||||
}
|
||||
if (issue.assigneeAgentId !== run.agentId) {
|
||||
return { kind: "skip", reason: "issue is no longer assigned to the source run agent" };
|
||||
}
|
||||
if (!CONTINUATION_ACTIVE_ISSUE_STATUSES.has(issue.status)) {
|
||||
return { kind: "skip", reason: `issue status ${issue.status} is not continuable` };
|
||||
}
|
||||
if (issue.executionState) {
|
||||
return { kind: "skip", reason: "issue is blocked by execution policy state" };
|
||||
}
|
||||
if (!CONTINUATION_AGENT_STATUSES.has(agent.status)) {
|
||||
return { kind: "skip", reason: `agent status ${agent.status} is not invokable` };
|
||||
}
|
||||
if (budgetBlocked) {
|
||||
return { kind: "skip", reason: "budget hard stop blocks continuation" };
|
||||
}
|
||||
|
||||
const currentAttempt = readContinuationAttempt(run.continuationAttempt);
|
||||
if (currentAttempt >= maxAttempts) {
|
||||
return {
|
||||
kind: "exhausted",
|
||||
attempt: currentAttempt,
|
||||
maxAttempts,
|
||||
comment: [
|
||||
"Bounded liveness continuation exhausted",
|
||||
"",
|
||||
`- Last liveness state: \`${livenessState}\``,
|
||||
`- Attempts used: ${currentAttempt}/${maxAttempts}`,
|
||||
`- Reason: ${livenessReason ?? "Run ended without concrete progress"}`,
|
||||
"- Next action: a human or manager should inspect the run and either clarify the task, mark it blocked, or assign a concrete follow-up.",
|
||||
].join("\n"),
|
||||
};
|
||||
}
|
||||
|
||||
const nextAttempt = currentAttempt + 1;
|
||||
const idempotencyKey = buildRunLivenessContinuationIdempotencyKey({
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
livenessState,
|
||||
nextAttempt,
|
||||
});
|
||||
if (idempotentWakeExists) {
|
||||
return { kind: "skip", reason: "continuation wake already exists for this source run and attempt" };
|
||||
}
|
||||
|
||||
const payload = {
|
||||
issueId: issue.id,
|
||||
sourceRunId: run.id,
|
||||
livenessState,
|
||||
livenessReason,
|
||||
continuationAttempt: nextAttempt,
|
||||
maxContinuationAttempts: maxAttempts,
|
||||
instruction:
|
||||
nextAction ??
|
||||
"The previous run ended without concrete progress. Take the first concrete action now or mark the issue blocked with a specific unblock request.",
|
||||
};
|
||||
|
||||
return {
|
||||
kind: "enqueue",
|
||||
nextAttempt,
|
||||
idempotencyKey,
|
||||
payload,
|
||||
contextSnapshot: {
|
||||
issueId: issue.id,
|
||||
taskId: issue.id,
|
||||
taskKey: issue.id,
|
||||
wakeReason: RUN_LIVENESS_CONTINUATION_REASON,
|
||||
livenessContinuationAttempt: nextAttempt,
|
||||
livenessContinuationMaxAttempts: maxAttempts,
|
||||
livenessContinuationSourceRunId: run.id,
|
||||
livenessContinuationState: livenessState,
|
||||
livenessContinuationReason: livenessReason,
|
||||
livenessContinuationInstruction: payload.instruction,
|
||||
},
|
||||
};
|
||||
}
|
||||
export {
|
||||
DEFAULT_MAX_LIVENESS_CONTINUATION_ATTEMPTS,
|
||||
RUN_LIVENESS_CONTINUATION_REASON,
|
||||
buildRunLivenessContinuationIdempotencyKey,
|
||||
decideRunLivenessContinuation,
|
||||
findExistingRunLivenessContinuationWake,
|
||||
readContinuationAttempt,
|
||||
} from "./recovery/run-liveness-continuations.js";
|
||||
export type {
|
||||
RunContinuationDecision,
|
||||
} from "./recovery/run-liveness-continuations.js";
|
||||
|
|
|
|||
|
|
@ -1,5 +1,12 @@
|
|||
import type { HeartbeatRunStatus, IssueStatus, RunLivenessState } from "@paperclipai/shared";
|
||||
|
||||
export type RunLivenessActionability =
|
||||
| "runnable"
|
||||
| "manager_review"
|
||||
| "blocked_external"
|
||||
| "approval_required"
|
||||
| "unknown";
|
||||
|
||||
export interface RunLivenessIssueInput {
|
||||
status: IssueStatus | string;
|
||||
title: string;
|
||||
|
|
@ -21,6 +28,8 @@ export interface RunLivenessClassificationInput {
|
|||
runStatus: HeartbeatRunStatus | string;
|
||||
issue: RunLivenessIssueInput | null;
|
||||
resultJson?: Record<string, unknown> | null;
|
||||
issueCommentBodies?: string[] | null;
|
||||
continuationSummaryBody?: string | null;
|
||||
stdoutExcerpt?: string | null;
|
||||
stderrExcerpt?: string | null;
|
||||
error?: string | null;
|
||||
|
|
@ -35,6 +44,7 @@ export interface RunLivenessClassification {
|
|||
continuationAttempt: number;
|
||||
lastUsefulActionAt: Date | null;
|
||||
nextAction: string | null;
|
||||
actionability: RunLivenessActionability;
|
||||
}
|
||||
|
||||
const DEFAULT_EVIDENCE: RunLivenessEvidenceInput = {
|
||||
|
|
@ -54,6 +64,14 @@ const NEXT_STEPS_RE = /^\s*(?:next steps?|plan)\s*:/im;
|
|||
const BLOCKER_RE =
|
||||
/\b(?:blocked|can't proceed|cannot proceed|unable to proceed|waiting on|need(?:s|ed)? .{0,80}\b(?:approval|access|credential|credentials|secret|api key|token|input|clarification)|requires? .{0,80}\b(?:approval|access|credential|credentials|secret|api key|token|input|clarification))\b/i;
|
||||
const NEGATED_BLOCKER_RE = /\b(?:not blocked|no blocker|no blockers|unblocked)\b/i;
|
||||
const APPROVAL_REQUIRED_RE =
|
||||
/\b(?:approval required|requires? .{0,80}\bapproval|need(?:s|ed)? .{0,80}\bapproval|waiting on .{0,80}\bapproval|pending approval|board approval|human approval|user approval|operator approval)\b/i;
|
||||
const EXTERNAL_BLOCKER_RE =
|
||||
/\b(?:can't proceed|cannot proceed|unable to proceed|waiting on|blocked by|blocked on|need(?:s|ed)?|requires?) .{0,120}\b(?:access|credential|credentials|secret|secrets|api key|token|password|login|account|permission|permissions|input|clarification)\b/i;
|
||||
const MANAGER_REVIEW_RE =
|
||||
/\b(?:manager review|human review|manual review|security review|escalate|production deploy|deploy(?:ing)? to production|deploy(?:ing)? to prod|prod deploy|production access|rotate .{0,40}\b(?:secret|key|token)|delete .{0,40}\bproduction|security-sensitive|credentialed operation|budget-sensitive|cost approval|spend approval)\b/i;
|
||||
const RUNNABLE_RE =
|
||||
/\b(?:(?:run|rerun|execute)\s+(?:pnpm|npm|yarn|bun|vitest|jest|pytest|cargo|go test|curl|tests?|typecheck|build|lint|package|verification)|(?:inspect|check|review|look|investigate|analy[sz]e|open|read|start|begin|continue|implement|fix|test|update|create|add|write|verify|validate|report)\b)/i;
|
||||
const PLAN_TASK_TITLE_RE = /\b(?:plan|planning|analysis|investigation|research|report|proposal|design doc|write-?up)\b/i;
|
||||
const PLAN_TASK_DESCRIPTION_RE =
|
||||
/\b(?:create|write|produce|draft|update|revise|prepare)\s+(?:a\s+|the\s+)?(?:plan|analysis|investigation|research report|report|proposal|design doc|write-?up)\b/i;
|
||||
|
|
@ -76,12 +94,22 @@ function readText(value: unknown): string | null {
|
|||
return trimmed.length > 0 ? trimmed : null;
|
||||
}
|
||||
|
||||
function resultText(resultJson: Record<string, unknown> | null | undefined) {
|
||||
function resultFinalText(resultJson: Record<string, unknown> | null | undefined) {
|
||||
if (!resultJson) return "";
|
||||
return [
|
||||
readText(resultJson.nextAction),
|
||||
readText(resultJson.summary),
|
||||
readText(resultJson.result),
|
||||
readText(resultJson.message),
|
||||
readText(resultJson.error),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
function resultRawText(resultJson: Record<string, unknown> | null | undefined) {
|
||||
if (!resultJson) return "";
|
||||
return [
|
||||
readText(resultJson.stdout),
|
||||
readText(resultJson.stderr),
|
||||
]
|
||||
|
|
@ -89,16 +117,34 @@ function resultText(resultJson: Record<string, unknown> | null | undefined) {
|
|||
.join("\n");
|
||||
}
|
||||
|
||||
function combinedOutput(input: RunLivenessClassificationInput) {
|
||||
function highSignalSources(input: RunLivenessClassificationInput) {
|
||||
return [
|
||||
resultText(input.resultJson),
|
||||
...(input.issueCommentBodies ?? []).map(readText),
|
||||
readText(resultFinalText(input.resultJson)),
|
||||
readText(input.continuationSummaryBody),
|
||||
].filter((value): value is string => Boolean(value));
|
||||
}
|
||||
|
||||
function rawSources(input: RunLivenessClassificationInput) {
|
||||
return [
|
||||
readText(resultRawText(input.resultJson)),
|
||||
readText(input.stdoutExcerpt),
|
||||
readText(input.stderrExcerpt),
|
||||
readText(input.error),
|
||||
]
|
||||
.filter((value): value is string => Boolean(value))
|
||||
.join("\n")
|
||||
.trim();
|
||||
.map(stripNoisyTranscriptLines)
|
||||
.filter((value) => value.length > 0);
|
||||
}
|
||||
|
||||
function combinedOutput(input: RunLivenessClassificationInput) {
|
||||
return [...highSignalSources(input), ...rawSources(input)].join("\n").trim();
|
||||
}
|
||||
|
||||
function actionabilityText(input: RunLivenessClassificationInput) {
|
||||
const highSignal = highSignalSources(input).join("\n").trim();
|
||||
if (highSignal) return highSignal;
|
||||
return rawSources(input).join("\n").trim();
|
||||
}
|
||||
|
||||
export function hasUsefulOutput(input: RunLivenessClassificationInput) {
|
||||
|
|
@ -107,15 +153,14 @@ export function hasUsefulOutput(input: RunLivenessClassificationInput) {
|
|||
|
||||
export function declaredBlocker(input: RunLivenessClassificationInput) {
|
||||
if (input.issue?.status === "blocked") return true;
|
||||
const text = combinedOutput(input);
|
||||
if (!text || NEGATED_BLOCKER_RE.test(text)) return false;
|
||||
return BLOCKER_RE.test(text);
|
||||
const actionability = classifyRunActionability(input);
|
||||
return actionability === "blocked_external" || actionability === "approval_required";
|
||||
}
|
||||
|
||||
export function looksLikePlanningOnly(input: RunLivenessClassificationInput) {
|
||||
const text = combinedOutput(input);
|
||||
const text = actionabilityText(input);
|
||||
if (!text) return false;
|
||||
return PLANNING_ONLY_RE.test(text) || NEXT_STEPS_RE.test(text);
|
||||
return PLANNING_ONLY_RE.test(text) || NEXT_STEPS_RE.test(text) || /^\s*next(?: steps?| action)?\s*:/im.test(text);
|
||||
}
|
||||
|
||||
export function isPlanningOrDocumentTask(issue: RunLivenessIssueInput | null | undefined) {
|
||||
|
|
@ -163,20 +208,92 @@ function evidenceReason(evidence: RunLivenessEvidenceInput) {
|
|||
return parts.join(", ");
|
||||
}
|
||||
|
||||
function extractNextAction(input: RunLivenessClassificationInput) {
|
||||
const text = combinedOutput(input);
|
||||
if (!text) return null;
|
||||
const line = text
|
||||
function stripMarkdownListPrefix(line: string) {
|
||||
return line.replace(/^\s*(?:[-*]|\d+\.)\s+/, "").trim();
|
||||
}
|
||||
|
||||
function isNoisyTranscriptLine(line: string) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return true;
|
||||
return (
|
||||
/^(?:command|status|exit_code|tool|tool_call|tool_result|stdout|stderr|event|payload|session|cwd|ref_id)\s*:/i.test(trimmed) ||
|
||||
/^(?:\{|\[).{0,80}(?:tool|event|stdout|stderr|cmd|command|payload)/i.test(trimmed) ||
|
||||
/^\$?\s*(?:rg|sed|cat|ls|git|pnpm|npm|yarn|curl|node|python)\b/i.test(trimmed)
|
||||
);
|
||||
}
|
||||
|
||||
function stripNoisyTranscriptLines(text: string) {
|
||||
return text
|
||||
.split(/\r?\n/)
|
||||
.map((entry) => entry.trim())
|
||||
.find((entry) => PLANNING_ONLY_RE.test(entry) || /^next(?: steps?| action)?\s*:/i.test(entry));
|
||||
if (!line) return null;
|
||||
return line.length <= 500 ? line : `${line.slice(0, 497)}...`;
|
||||
.map((line) => line.trim())
|
||||
.filter((line) => !isNoisyTranscriptLine(line))
|
||||
.join("\n")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function nextNonNoiseLine(lines: string[], startIndex: number) {
|
||||
for (let i = startIndex + 1; i < lines.length; i += 1) {
|
||||
const line = stripMarkdownListPrefix(lines[i] ?? "");
|
||||
if (!line || isNoisyTranscriptLine(line)) continue;
|
||||
return line;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractNextActionFromText(text: string) {
|
||||
const lines = text.split(/\r?\n/).map((entry) => entry.trim());
|
||||
for (let i = 0; i < lines.length; i += 1) {
|
||||
const rawLine = lines[i] ?? "";
|
||||
if (!rawLine || isNoisyTranscriptLine(rawLine)) continue;
|
||||
const line = stripMarkdownListPrefix(rawLine);
|
||||
const labeled = line.match(/^next(?: steps?| action)?\s*:\s*(.*)$/i);
|
||||
if (labeled) {
|
||||
const sameLine = stripMarkdownListPrefix(labeled[1] ?? "");
|
||||
return sameLine || nextNonNoiseLine(lines, i);
|
||||
}
|
||||
if (PLANNING_ONLY_RE.test(line)) return line;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function extractNextAction(input: RunLivenessClassificationInput) {
|
||||
const structuredNextAction = readText(input.resultJson?.nextAction);
|
||||
const candidates = [
|
||||
...(input.issueCommentBodies ?? []),
|
||||
structuredNextAction ? `Next action: ${structuredNextAction}` : null,
|
||||
resultFinalText(input.resultJson),
|
||||
input.continuationSummaryBody,
|
||||
...rawSources(input),
|
||||
].filter((value): value is string => Boolean(readText(value)));
|
||||
|
||||
for (const candidate of candidates) {
|
||||
const line = extractNextActionFromText(candidate);
|
||||
if (!line) continue;
|
||||
return line.length <= 500 ? line : `${line.slice(0, 497)}...`;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export function classifyRunActionability(input: RunLivenessClassificationInput): RunLivenessActionability {
|
||||
const text = actionabilityText(input);
|
||||
if (!text) return "unknown";
|
||||
if (NEGATED_BLOCKER_RE.test(text)) {
|
||||
return RUNNABLE_RE.test(text) ? "runnable" : "unknown";
|
||||
}
|
||||
if (APPROVAL_REQUIRED_RE.test(text)) return "approval_required";
|
||||
if (EXTERNAL_BLOCKER_RE.test(text) || BLOCKER_RE.test(text) && /\b(?:credential|secret|api key|token|access|input|clarification)\b/i.test(text)) {
|
||||
return "blocked_external";
|
||||
}
|
||||
if (MANAGER_REVIEW_RE.test(text)) return "manager_review";
|
||||
if (RUNNABLE_RE.test(text)) return "runnable";
|
||||
return "unknown";
|
||||
}
|
||||
|
||||
export function classifyRunLiveness(input: RunLivenessClassificationInput): RunLivenessClassification {
|
||||
const evidence = normalizeEvidence(input.evidence);
|
||||
const continuationAttempt = normalizeContinuationAttempt(input.continuationAttempt);
|
||||
const actionability = classifyRunActionability(input);
|
||||
const nextAction = extractNextAction(input);
|
||||
const issueStatus = input.issue?.status ?? null;
|
||||
const usefulOutput = hasUsefulOutput(input);
|
||||
const concreteEvidence = hasConcreteActionEvidence(evidence);
|
||||
|
|
@ -189,6 +306,7 @@ export function classifyRunLiveness(input: RunLivenessClassificationInput): RunL
|
|||
continuationAttempt,
|
||||
lastUsefulActionAt: state === "advanced" || state === "completed" || state === "blocked" ? lastUsefulActionAt : null,
|
||||
nextAction,
|
||||
actionability,
|
||||
});
|
||||
|
||||
if (input.runStatus !== "succeeded") {
|
||||
|
|
@ -200,7 +318,7 @@ export function classifyRunLiveness(input: RunLivenessClassificationInput): RunL
|
|||
}
|
||||
|
||||
if (declaredBlocker(input)) {
|
||||
return output("blocked", issueStatus === "blocked" ? "Issue status is blocked" : "Run output declared a concrete blocker", extractNextAction(input));
|
||||
return output("blocked", issueStatus === "blocked" ? "Issue status is blocked" : "Run output declared a concrete blocker", nextAction);
|
||||
}
|
||||
|
||||
if (!usefulOutput && !concreteEvidence) {
|
||||
|
|
@ -215,12 +333,15 @@ export function classifyRunLiveness(input: RunLivenessClassificationInput): RunL
|
|||
return output("advanced", "Planning/document task produced useful output and is exempt from plan-only classification");
|
||||
}
|
||||
|
||||
if (looksLikePlanningOnly(input)) {
|
||||
return output("plan_only", "Run described future work without concrete action evidence", extractNextAction(input));
|
||||
if (looksLikePlanningOnly(input) || nextAction) {
|
||||
if (actionability === "runnable") {
|
||||
return output("plan_only", "Run described runnable future work without concrete action evidence", nextAction);
|
||||
}
|
||||
return output("needs_followup", "Run described future work that is not safe to auto-continue", nextAction);
|
||||
}
|
||||
|
||||
if (usefulOutput) {
|
||||
return output("needs_followup", "Run produced useful output but no concrete action evidence", extractNextAction(input));
|
||||
return output("needs_followup", "Run produced useful output but no concrete action evidence", nextAction);
|
||||
}
|
||||
|
||||
return output("empty_response", "Run succeeded without useful output");
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export interface RunLogStore {
|
|||
append(
|
||||
handle: RunLogHandle,
|
||||
event: { stream: "stdout" | "stderr" | "system"; chunk: string; ts: string },
|
||||
): Promise<void>;
|
||||
): Promise<number>;
|
||||
finalize(handle: RunLogHandle): Promise<RunLogFinalizeSummary>;
|
||||
read(handle: RunLogHandle, opts?: RunLogReadOptions): Promise<RunLogReadResult>;
|
||||
}
|
||||
|
|
@ -107,14 +107,16 @@ function createLocalFileRunLogStore(basePath: string): RunLogStore {
|
|||
},
|
||||
|
||||
async append(handle, event) {
|
||||
if (handle.store !== "local_file") return;
|
||||
if (handle.store !== "local_file") return 0;
|
||||
const absPath = resolveWithin(basePath, handle.logRef);
|
||||
const line = JSON.stringify({
|
||||
ts: event.ts,
|
||||
stream: event.stream,
|
||||
chunk: event.chunk,
|
||||
});
|
||||
await fs.appendFile(absPath, `${line}\n`, "utf8");
|
||||
const persisted = `${line}\n`;
|
||||
await fs.appendFile(absPath, persisted, "utf8");
|
||||
return Buffer.byteLength(persisted, "utf8");
|
||||
},
|
||||
|
||||
async finalize(handle) {
|
||||
|
|
@ -153,4 +155,3 @@ export function getRunLogStore() {
|
|||
cachedStore = createLocalFileRunLogStore(basePath);
|
||||
return cachedStore;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue