koala73/worldmonitor · error · ConvexError
COMPANY_MONITORING_CLASSIFICATION_FENCED
COMPANY_MONITORING_CLASSIFICATION_FENCED
Error message
COMPANY_MONITORING_CLASSIFICATION_FENCED
What it means
Thrown by the classification admission mutation (convex/companyMonitoring/admission.ts:467) when the candidate does not match the submitted fence: candidate missing, state not pending_classification, evidenceRevision different from expectedEvidenceRevision, classificationWorkerId/classificationLeaseToken/classificationRunId/classificationRequestedModelVersion different from the args, or classificationLeaseExpiresAt missing/elapsed. It is lease fencing — only the worker currently holding the 5-minute lease (ADMISSION_LEASE_MS) may record a decision.
Solutions
- Re-acquire: re-run the claim flow to get a fresh leaseToken + classificationRunId and a current expectedEvidenceRevision, then re-classify
- Keep the model call plus submission well under 5 minutes, or checkpoint and complete inside the lease window
- Always read workerId/leaseToken/runId from the claim response and pass them back verbatim — never from a cache or previous attempt
- Treat FENCED as a normal loss-of-lease outcome (drop the result), not an error to hammer on
Example fix
// before
const decision = await callModel(candidate); // may take > 5 min
await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {
workerId, leaseToken, classificationRunId, // from an old claim
...
});
// after
let decision = await callModel(candidate);
try {
await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {
workerId, leaseToken, classificationRunId, expectedEvidenceRevision,
...
});
} catch (err) {
if (err instanceof ConvexError && err.data === "COMPANY_MONITORING_CLASSIFICATION_FENCED") {
const reclaimed = await claimClassification({ ... }); // fresh lease + runId + revision
decision = await callModel(reclaimed.candidate);
await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, {
...reclaimed, modelOutput: decision, ...
});
} else throw err;
} Defensive patterns
Strategy: retry
Validate before calling
// Before the model call, confirm the lease you hold is current and unexpired.
const candidate = await loadCandidate(ownerAccountId, companyId, occurrenceDedupeKey);
const leaseValid =
candidate?.state === "pending_classification" &&
candidate.classificationWorkerId === workerId &&
candidate.classificationLeaseToken === leaseToken &&
candidate.classificationRunId === classificationRunId &&
candidate.classificationLeaseExpiresAt !== undefined &&
candidate.classificationLeaseExpiresAt > Date.now() + SAFETY_MARGIN_MS;
if (!leaseValid) {
const reclaimed = await claimClassification({ ownerAccountId, companyId, occurrenceDedupeKey });
Object.assign(args, reclaimed); // fresh workerId/leaseToken/runId/revision
} Type guard
function holdsLiveLease(candidate: Doc<"companyMonitoringCandidates"> | null, fence: { workerId: string; leaseToken: string; classificationRunId: string; requestedModelVersion: string; evidenceRevision: number }): candidate is Doc<"companyMonitoringCandidates"> {
return (
!!candidate &&
candidate.state === "pending_classification" &&
candidate.evidenceRevision === fence.evidenceRevision &&
candidate.classificationWorkerId === fence.workerId &&
candidate.classificationLeaseToken === fence.leaseToken &&
candidate.classificationRunId === fence.classificationRunId &&
candidate.classificationRequestedModelVersion === fence.requestedModelVersion &&
candidate.classificationLeaseExpiresAt !== undefined &&
candidate.classificationLeaseExpiresAt > Date.now()
);
} Try / catch
try {
await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, args);
} catch (err) {
if (err instanceof ConvexError && err.data === "COMPANY_MONITORING_CLASSIFICATION_FENCED") {
const reclaimed = await claimClassification({ ownerAccountId, companyId, occurrenceDedupeKey });
if (reclaimed) {
const modelOutput = await callModel(reclaimed.candidate);
await ctx.runMutation(internal.companyMonitoring.admission.recordClassification, { ...reclaimed.args, modelOutput });
}
return; // if reclaim failed, another worker owns it — stand down
}
throw err;
} Prevention
- Complete model call plus submission well inside the 5-minute lease (ADMISSION_LEASE_MS)
- Pass workerId/leaseToken/runId verbatim from the claim response — never from cache
- Treat FENCED as loss-of-lease: re-claim and re-classify, do not blind-retry
- Renew or shorten work units if model latency approaches the lease window
When it happens
Trigger: Submitting after the 5-minute lease expired and another worker re-claimed the candidate; submitting with a stale lease token read before a re-claim; the candidate terminalized (published/rejected/expired) between claim and submit; passing a modelVersion pair that differs from classificationRequestedModelVersion captured at claim time; retrying an old run after a re-scan bumped evidenceRevision.
Common situations: LLM calls slower than the 5-minute lease; worker crashes and retries with cached credentials; two workers processing the same occurrence after a queue redelivery; clock drift; paused/removed company cancelling scan work mid-flight.
Related errors
- Authentication unavailable while loading Business Pro…
- Authentication unavailable while loading MCP clients. Try…
- Convex embed key validation unavailable: fetch-error
- REDIS_DOWN
- Revoke service is temporarily unavailable. Try again in a…
AI-assisted analysis of koala73/worldmonitor@eeab0a219f (2026-08-21).
Data as JSON: /api/errors/4d3ad3d87e5bf653.
Report an issue: GitHub.
Appendix: source
Thrown at convex/companyMonitoring/admission.ts:467
q
.eq("ownerAccountId", args.ownerAccountId)
.eq("companyId", args.companyId)
.eq("occurrenceDedupeKey", args.occurrenceDedupeKey),
)
.unique();
const now = Date.now();
if (
!candidate ||
candidate.state !== "pending_classification" ||
candidate.evidenceRevision !== args.expectedEvidenceRevision ||
candidate.classificationWorkerId !== workerId ||
candidate.classificationLeaseToken !== leaseToken ||
candidate.classificationRunId !== classificationRunId ||
candidate.classificationRequestedModelVersion !== requestedModelVersion ||
candidate.classificationLeaseExpiresAt === undefined ||
candidate.classificationLeaseExpiresAt <= now
) {
throw new ConvexError("COMPANY_MONITORING_CLASSIFICATION_FENCED");
}
if (!await admissionScopeIsActive(ctx, candidate)) {
throw new ConvexError("COMPANY_MONITORING_CLASSIFICATION_FENCED");
}
if (candidate.expiresAt <= now) {
await terminalizeSystemDecision(
ctx,
candidate,
"expire",
"candidate_expired",
"hold_expired",
now,
);
return { status: "recorded" as const, decision: "expire" as const };
}
const submissionDigest = await fingerprint(canonicalValue({
requestedModelVersion,
modelVersion,View on GitHub (pinned to eeab0a219f)