mirror of
https://github.com/alkimake/paperclip.git
synced 2026-06-16 10:50:38 +09:00
[codex] Harden execution reliability and heartbeat tooling (#3679)
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies > - Reliable execution depends on heartbeat routing, issue lifecycle semantics, telemetry, and a fast enough local verification loop to keep regressions visible > - The remaining commits on this branch were mostly server/runtime correctness fixes plus test and documentation follow-ups in that area > - Those changes are logically separate from the UI-focused issue-detail and workspace/navigation branches even when they touch overlapping issue APIs > - This pull request groups the execution reliability, heartbeat, telemetry, and tooling changes into one standalone branch > - The benefit is a focused review of the control-plane correctness work, including the follow-up fix that restored the implicit comment-reopen helpers after branch splitting ## What Changed - Hardened issue/heartbeat execution behavior, including self-review stage skipping, deferred mention wakes during active execution, stranded execution recovery, active-run scoping, assignee resolution, and blocked-to-todo wake resumption - Reduced noisy polling/logging overhead by trimming issue run payloads, compacting persisted run logs, silencing high-volume request logs, and capping heartbeat-run queries in dashboard/inbox surfaces - Expanded telemetry and status semantics with adapter/model fields on task completion plus clearer status guidance in docs/onboarding material - Updated test infrastructure and verification defaults with faster route-test module isolation, cheaper default `pnpm test`, e2e isolation from local state, and repo verification follow-ups - Included docs/release housekeeping from the branch and added a small follow-up commit restoring the implicit comment-reopen helpers that were dropped during branch reconstruction ## Verification - `pnpm vitest run server/src/__tests__/issue-comment-reopen-routes.test.ts server/src/__tests__/issue-telemetry-routes.test.ts` - `pnpm vitest run server/src/__tests__/http-log-policy.test.ts server/src/__tests__/heartbeat-run-log.test.ts server/src/__tests__/health.test.ts` - `server/src/__tests__/activity-service.test.ts`, `server/src/__tests__/heartbeat-comment-wake-batching.test.ts`, and `server/src/__tests__/heartbeat-process-recovery.test.ts` were attempted on this host but the embedded Postgres harness reported init-script/data-dir problems and skipped or failed to start, so they are noted as environment-limited ## Risks - Medium: this branch changes core issue/heartbeat routing and reopen/wakeup behavior, so regressions would affect agent execution flow rather than isolated UI polish - Because it also updates verification infrastructure, reviewers should pay attention to whether the new tests are asserting the right failure modes and not just reshaping harness behavior ## Model Used - OpenAI Codex coding agent (GPT-5-class runtime in Codex CLI; exact deployed model ID is not exposed in this environment), reasoning enabled, tool use and local code execution enabled ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [ ] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [ ] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
parent
e89076148a
commit
7f893ac4ec
106 changed files with 4682 additions and 713 deletions
|
|
@ -587,7 +587,11 @@ export function adapterRoutes() {
|
|||
// Serve a declarative config schema for an adapter's UI form fields.
|
||||
// The adapter's getConfigSchema() resolves all options (static and dynamic)
|
||||
// so the UI receives a fully hydrated schema in a single fetch.
|
||||
const configSchemaCache = new Map<string, { schema: AdapterConfigSchema; fetchedAt: number }>();
|
||||
const configSchemaCache = new Map<string, {
|
||||
adapter: ServerAdapterModule;
|
||||
schema: AdapterConfigSchema;
|
||||
fetchedAt: number;
|
||||
}>();
|
||||
const CONFIG_SCHEMA_TTL_MS = 30_000;
|
||||
|
||||
router.get("/adapters/:type/config-schema", async (req, res) => {
|
||||
|
|
@ -605,14 +609,14 @@ export function adapterRoutes() {
|
|||
}
|
||||
|
||||
const cached = configSchemaCache.get(type);
|
||||
if (cached && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) {
|
||||
if (cached && cached.adapter === adapter && Date.now() - cached.fetchedAt < CONFIG_SCHEMA_TTL_MS) {
|
||||
res.json(cached.schema);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const schema = await adapter.getConfigSchema();
|
||||
configSchemaCache.set(type, { schema, fetchedAt: Date.now() });
|
||||
configSchemaCache.set(type, { adapter, schema, fetchedAt: Date.now() });
|
||||
res.json(schema);
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
|
|
|
|||
|
|
@ -964,6 +964,13 @@ export function agentRoutes(db: Db) {
|
|||
router.get("/companies/:companyId/agents", async (req, res) => {
|
||||
const companyId = req.params.companyId as string;
|
||||
assertCompanyAccess(req, companyId);
|
||||
const unsupportedQueryParams = Object.keys(req.query).sort();
|
||||
if (unsupportedQueryParams.length > 0) {
|
||||
res.status(400).json({
|
||||
error: `Unsupported query parameter${unsupportedQueryParams.length === 1 ? "" : "s"}: ${unsupportedQueryParams.join(", ")}`,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const result = await svc.list(companyId);
|
||||
const canReadConfigs = await actorCanReadConfigurationsForCompany(req, companyId);
|
||||
if (canReadConfigs || req.actor.type === "board") {
|
||||
|
|
@ -1426,7 +1433,7 @@ export function agentRoutes(db: Db) {
|
|||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackAgentCreated(telemetryClient, { agentRole: agent.role });
|
||||
trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id });
|
||||
}
|
||||
|
||||
await applyDefaultAgentTaskAssignGrant(
|
||||
|
|
@ -1514,7 +1521,7 @@ export function agentRoutes(db: Db) {
|
|||
});
|
||||
const telemetryClient = getTelemetryClient();
|
||||
if (telemetryClient) {
|
||||
trackAgentCreated(telemetryClient, { agentRole: agent.role });
|
||||
trackAgentCreated(telemetryClient, { agentRole: agent.role, agentId: agent.id });
|
||||
}
|
||||
|
||||
await applyDefaultAgentTaskAssignGrant(
|
||||
|
|
@ -2442,7 +2449,13 @@ export function agentRoutes(db: Db) {
|
|||
assertCompanyAccess(req, issue.companyId);
|
||||
|
||||
let run = issue.executionRunId ? await heartbeat.getRunIssueSummary(issue.executionRunId) : null;
|
||||
if (run && run.status !== "queued" && run.status !== "running") {
|
||||
if (
|
||||
run &&
|
||||
(
|
||||
(run.status !== "queued" && run.status !== "running") ||
|
||||
run.issueId !== issue.id
|
||||
)
|
||||
) {
|
||||
run = null;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -46,7 +46,7 @@ import {
|
|||
workProductService,
|
||||
} from "../services/index.js";
|
||||
import { logger } from "../middleware/logger.js";
|
||||
import { forbidden, HttpError, unauthorized } from "../errors.js";
|
||||
import { conflict, forbidden, HttpError, notFound, unauthorized } from "../errors.js";
|
||||
import { assertCompanyAccess, getActorInfo } from "./authz.js";
|
||||
import { shouldWakeAssigneeOnCheckout } from "./issues-checkout-wakeup.js";
|
||||
import {
|
||||
|
|
@ -460,6 +460,28 @@ export function issueRoutes(
|
|||
return runToInterrupt?.status === "running" ? runToInterrupt : null;
|
||||
}
|
||||
|
||||
async function normalizeIssueAssigneeAgentReference(
|
||||
companyId: string,
|
||||
rawAssigneeAgentId: string | null | undefined,
|
||||
) {
|
||||
if (rawAssigneeAgentId === undefined || rawAssigneeAgentId === null) {
|
||||
return rawAssigneeAgentId;
|
||||
}
|
||||
|
||||
const raw = rawAssigneeAgentId.trim();
|
||||
if (raw.length === 0) {
|
||||
return rawAssigneeAgentId;
|
||||
}
|
||||
|
||||
const resolved = await agentsSvc.resolveByReference(companyId, raw);
|
||||
if (resolved.ambiguous) {
|
||||
throw conflict("Agent shortname is ambiguous in this company. Use the agent ID.");
|
||||
}
|
||||
if (!resolved.agent) {
|
||||
throw notFound("Agent not found");
|
||||
}
|
||||
return resolved.agent.id;
|
||||
}
|
||||
function toValidTimestamp(value: Date | string | null | undefined) {
|
||||
if (!value) return null;
|
||||
const timestamp = value instanceof Date ? value.getTime() : new Date(value).getTime();
|
||||
|
|
@ -485,7 +507,6 @@ export function issueRoutes(
|
|||
if (params.comment.authorAgentId && params.comment.authorAgentId === params.activeRun.agentId) return false;
|
||||
return commentCreatedAtMs >= activeRunStartedAtMs;
|
||||
}
|
||||
|
||||
async function getClosedIssueExecutionWorkspace(issue: { executionWorkspaceId?: string | null }) {
|
||||
if (!issue.executionWorkspaceId) return null;
|
||||
const workspace = await executionWorkspacesSvc.getById(issue.executionWorkspaceId);
|
||||
|
|
@ -1356,6 +1377,10 @@ export function issueRoutes(
|
|||
|
||||
const actor = getActorInfo(req);
|
||||
const isClosed = isClosedIssueStatus(existing.status);
|
||||
const normalizedAssigneeAgentId = await normalizeIssueAssigneeAgentReference(
|
||||
existing.companyId,
|
||||
req.body.assigneeAgentId as string | null | undefined,
|
||||
);
|
||||
const existingRelations =
|
||||
Array.isArray(req.body.blockedByIssueIds)
|
||||
? await svc.getRelationSummaries(existing.id)
|
||||
|
|
@ -1368,7 +1393,7 @@ export function issueRoutes(
|
|||
...updateFields
|
||||
} = req.body;
|
||||
const requestedAssigneeAgentId =
|
||||
req.body.assigneeAgentId === undefined ? existing.assigneeAgentId : (req.body.assigneeAgentId as string | null);
|
||||
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
|
||||
const effectiveReopenRequested =
|
||||
reopenRequested ||
|
||||
(!!commentBody &&
|
||||
|
|
@ -1431,14 +1456,16 @@ export function issueRoutes(
|
|||
updateFields.executionPolicy !== undefined
|
||||
? (updateFields.executionPolicy as NormalizedExecutionPolicy | null)
|
||||
: previousExecutionPolicy;
|
||||
if (normalizedAssigneeAgentId !== undefined) {
|
||||
updateFields.assigneeAgentId = normalizedAssigneeAgentId;
|
||||
}
|
||||
|
||||
const transition = applyIssueExecutionPolicyTransition({
|
||||
issue: existing,
|
||||
policy: nextExecutionPolicy,
|
||||
requestedStatus: typeof updateFields.status === "string" ? updateFields.status : undefined,
|
||||
requestedAssigneePatch: {
|
||||
assigneeAgentId:
|
||||
req.body.assigneeAgentId === undefined ? undefined : (req.body.assigneeAgentId as string | null),
|
||||
assigneeAgentId: normalizedAssigneeAgentId,
|
||||
assigneeUserId:
|
||||
req.body.assigneeUserId === undefined ? undefined : (req.body.assigneeUserId as string | null),
|
||||
},
|
||||
|
|
@ -1527,8 +1554,7 @@ export function issueRoutes(
|
|||
issueId: id,
|
||||
companyId: existing.companyId,
|
||||
assigneePatch: {
|
||||
assigneeAgentId:
|
||||
req.body.assigneeAgentId === undefined ? "__omitted__" : req.body.assigneeAgentId,
|
||||
assigneeAgentId: normalizedAssigneeAgentId === undefined ? "__omitted__" : normalizedAssigneeAgentId,
|
||||
assigneeUserId:
|
||||
req.body.assigneeUserId === undefined ? "__omitted__" : req.body.assigneeUserId,
|
||||
},
|
||||
|
|
@ -1682,7 +1708,13 @@ export function issueRoutes(
|
|||
if (tc && actor.agentId) {
|
||||
const actorAgent = await agentsSvc.getById(actor.agentId);
|
||||
if (actorAgent) {
|
||||
trackAgentTaskCompleted(tc, { agentRole: actorAgent.role });
|
||||
const model = typeof actorAgent.adapterConfig?.model === "string" ? actorAgent.adapterConfig.model : undefined;
|
||||
trackAgentTaskCompleted(tc, {
|
||||
agentRole: actorAgent.role,
|
||||
agentId: actorAgent.id,
|
||||
adapterType: actorAgent.adapterType,
|
||||
model,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1722,6 +1754,10 @@ export function issueRoutes(
|
|||
existing.status === "backlog" &&
|
||||
issue.status !== "backlog" &&
|
||||
req.body.status !== undefined;
|
||||
const statusChangedFromBlockedToTodo =
|
||||
existing.status === "blocked" &&
|
||||
issue.status === "todo" &&
|
||||
req.body.status !== undefined;
|
||||
const previousExecutionState = parseIssueExecutionState(existing.executionState);
|
||||
const nextExecutionState = parseIssueExecutionState(issue.executionState);
|
||||
const executionStageWakeup = buildExecutionStageWakeup({
|
||||
|
|
@ -1775,7 +1811,7 @@ export function issueRoutes(
|
|||
});
|
||||
}
|
||||
|
||||
if (!assigneeChanged && statusChangedFromBacklog && issue.assigneeAgentId) {
|
||||
if (!assigneeChanged && (statusChangedFromBacklog || statusChangedFromBlockedToTodo) && issue.assigneeAgentId) {
|
||||
addWakeup(issue.assigneeAgentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue