twentyhq/twenty · error · Error
on-opportunity-partner-assigned: ${failed} person clear(s) f
Error message
on-opportunity-partner-assigned: ${failed} person clear(s) failed — retrying What it means
propagatePartnerUser's unassign path runs all Person partnerUser-clears via Promise.allSettled and throws if any rejected. The throw is intentional: the partner stamp/clear is designed idempotently so a re-invocation redoes exactly the remaining work. It prevents a partially cleared cascade from being reported as success, which would leave stale partnerUser stamps that leak RLS visibility.
Source
Thrown at packages/twenty-apps/internal/twenty-partners/src/modules/opportunity/matching/services/propagate-partner-user.service.ts:109
removedMemberId,
});
if ((stillInUse.opportunities?.edges?.length ?? 0) > 0) {
return { cascaded: true, cleared: true, companyKept: true };
}
// Clear the company (only if it belongs to this member) and every person stamped for them.
const companyResult = await getCompanyPartnerUser(client, companyId);
if (companyResult.company?.partnerUserId === removedMemberId) {
await updateCompanyPartnerUser(client, companyId, null);
}
const peopleIds = await collectPeopleIds(client, {
companyId: { eq: companyId },
partnerUserId: { eq: removedMemberId },
});
const failed = await setPeoplePartnerUser(client, peopleIds, null);
if (failed > 0) {
throw new Error(
`on-opportunity-partner-assigned: ${failed} person clear(s) failed — retrying`,
);
}
return { cascaded: true, cleared: true, companyCleared: true };
}
// ── Assign / reassign ────────────────────────────────────────────────────────
const partnerResult = await getPartnerOwner(client, partnerId);
const partnerUserId = partnerResult.partner?.partnerUserId;
if (!partnerUserId) return { cascaded: false, reason: 'partner_has_no_user' };
await updateOpportunityPartnerUser(client, opportunityId, partnerUserId);
const companyId = after?.companyId;
if (!companyId) return { cascaded: true, partnerUserId };
// Don't clobber a company already owned by a DIFFERENT partner member. The single
// partnerUser column on Company/Person models one owner per company, so reassigning itView on GitHub (pinned to 1f5dd2bbd2)
Solutions
- Retry the operation — the function is idempotent (filters by partnerUserId === removedMemberId, so already-cleared rows drop out).
- If it persists, inspect which personIds rejected (add per-promise logging) to find the offending record.
- Check app-server logs for 5xx or RLS denials on updatePerson within the retry window.
- Confirm the workflow engine calling this logic-function has retry-with-backoff configured for thrown errors.
Example fix
// before
const failed = await setPeoplePartnerUser(client, peopleIds, null);
if (failed > 0) {
throw new Error(`on-opportunity-partner-assigned: ${failed} person clear(s) failed — retrying`);
}
// after — keep the throw (it drives idempotent retry), but log WHICH failed for diagnosis
const failedIds = await setPeoplePartnerUserDetailed(client, peopleIds, null);
if (failedIds.length > 0) {
console.error(`person clear failures for ${opportunityId}:`, failedIds);
throw new Error(`on-opportunity-partner-assigned: ${failedIds.length} person clear(s) failed — retrying`);
} Defensive patterns
Strategy: retry
Validate before calling
// The cascade is inherently non-atomic; the best pre-check is to ensure the // workflow engine has retry-with-backoff enabled for thrown logic-function errors. // No client-side pre-validation can prevent a transient mid-cascade rejection.
Try / catch
// propagatePartnerUser is a logic-function entrypoint; let the workflow engine
// retry on throw. If calling directly, wrap with bounded retry:
for (let attempt = 0; attempt < 3; attempt++) {
try {
return await propagatePartnerUser(client, params);
} catch (err) {
if (attempt === 2) throw err;
await new Promise((r) => setTimeout(r, 2 ** attempt * 500));
}
} Prevention
- Rely on the function's idempotent design — re-running clears only remaining rows.
- Ensure the workflow engine invoking this logic-function retries thrown errors with backoff.
- Monitor app-server logs for RLS denials or 5xx on updatePerson during cascades.
- Avoid concurrent assign/unassign churn on the same opportunity where possible.
When it happens
Trigger: One or more updatePersonPartnerUser(client, id, null) promises reject during the unassign cascade. Causes: transient network/5xx on a single update; a Person record deleted between collectPeopleIds and the update; an RLS field-lock kicking in mid-cascade; concurrent reassignment mutating the same rows.
Common situations: Brief flapping under load (assign/unassign churn); a Person removed by another workflow while the cascade runs; the app-server restarted mid-batch; large company with many people where one update hits a timeout.
Related errors
- on-opportunity-partner-assigned: ${failed} person stamp(s) f
- link-partner-user: ${failed} cascade write(s) failed for ${p
- Missing env var: ${name}
- GraphQL errors: ${json.errors.map((e) => e.message).join(';
- Field "${fieldName}" not found on object "${objectName}" aft
AI-assisted analysis of twentyhq/twenty@1f5dd2bbd2 (2026-08-12).
Data as JSON: /api/errors/8b4305d0d57f8f10.
Report an issue: GitHub.