mastra-ai/mastra · error · Error

GitHub subscription is missing its session binding.

Error message

GitHub subscription ${subscription.id} is missing its session binding.

What it means

resolveSubscriptionSession maps an incoming GitHub webhook-triggered signal subscription back to its Factory session. Every subscription row must carry sessionId, resourceId, and threadId; any missing field means the subscription was persisted incompletely and cannot be resolved to a runnable session. The code throws with the subscription ID so the broken row can be located and repaired.

Solutions

  1. Inspect the subscription row by ID in integration storage and fill in the missing sessionId/resourceId/threadId, or retire the broken subscription
  2. Re-create the subscription from a healthy Factory session so all three binding fields persist
  3. Add a NOT NULL / validation constraint or creation-path check so subscriptions are never written without a full session binding
  4. In the webhook dispatcher, catch this error per-subscription and skip/log it instead of failing the whole delivery

Example fix

// before
const { sessionId, resourceId, threadId } = subscription;
if (!sessionId || !resourceId || !threadId) throw new Error(`...missing its session binding.`);
// after (defensive dispatch)
try {
  const session = await resolveSubscriptionSession(controller, subscription, github);
} catch (err) {
  logger.warn('Skipping subscription %s: %s', subscription.id, (err as Error).message);
  await retireSubscription(subscription.id, status);
  return null;
}
Defensive patterns

Strategy: validation

Validate before calling

function subscriptionIsFullyBound(s: GithubSignalSubscriptionRow): boolean {
  return Boolean(s.sessionId && s.resourceId && s.threadId);
}
if (!subscriptionIsFullyBound(subscription)) {
  logger.warn('Skipping unbound subscription', { id: subscription.id });
  return null;
}

Type guard

function hasSessionBinding(s: GithubSignalSubscriptionRow): s is GithubSignalSubscriptionRow &
  { sessionId: string; resourceId: string; threadId: string } {
  return typeof s.sessionId === 'string' && s.sessionId.length > 0 &&
    typeof s.resourceId === 'string' && s.resourceId.length > 0 &&
    typeof s.threadId === 'string' && s.threadId.length > 0;
}

Try / catch

try {
  const session = await resolveSubscriptionSession(controller, subscription, github);
} catch (err) {
  if ((err as Error).message.includes('missing its session binding')) {
    await retireSubscription(subscription.id, 'closed');
    logger.warn('Retired subscription with missing binding', { id: subscription.id });
    return;
  }
  throw err;
}

Prevention

When it happens

Trigger: A GitHub PR webhook fires and dispatchGithubWebhook resolves a subscription row (from listPullRequestSubscriptionsForWebhook) whose sessionId, resourceId, or threadId column is null/empty when resolveSubscriptionSession runs.

Common situations: A subscription was created before the session-binding columns were added (schema migration gap); the session that created the subscription crashed mid-persist; manual DB edits or a partial write left the row incomplete; subscription created via an older code path that did not capture threadId.

Related errors


AI-assisted analysis of mastra-ai/mastra@75dd419e61 (2026-08-30). Data as JSON: /api/errors/b1e523d9067710ea. Report an issue: GitHub.

Appendix: source

Thrown at mastracode/factory/src/integrations/github/webhook.ts:337

    terminal,
    metadata: {
      ...metadata,
      pullRequestNumber: metadata.pullRequestNumber,
      repositoryId: metadata.repositoryId,
      installationId: metadata.installationId,
    },
    payload,
  };
}

async function resolveSubscriptionSession(
  controller: MountedMastraCode['controller'],
  subscription: GithubSignalSubscriptionRow,
  github?: GithubWebhookDispatchIntegration,
) {
  const { sessionId, resourceId, threadId } = subscription;
  if (!sessionId || !resourceId || !threadId) {
    throw new Error(`GitHub subscription ${subscription.id} is missing its session binding.`);
  }
  // Read the thread straight from storage before touching sessions. This answers
  // two questions at once, and `queryThreadById` does it without constructing a
  // session (so no workspace or sandbox is provisioned just to make the check).
  //
  // First: do we even have this thread? A pull request's events can reach a
  // deployment that never owned the subscribed thread, and delivery must not
  // fabricate a session for a thread that lives somewhere else.
  //
  // Second: which resource owns it? The subscription records the Factory project
  // as its `resourceId`, but an unscoped session is registered under its own id,
  // so the stored value routinely names a resource that does not own the thread.
  // The thread row is the authoritative answer; the stored id is only a fallback.
  const thread = await controller.queryThreadById({ threadId });
  if (!thread) return undefined;
  const ownerResourceId = thread.resourceId || resourceId;
  const scope = subscription.sessionScope || undefined;
  let session = await controller.getSessionByResource(ownerResourceId, scope);

View on GitHub (pinned to 75dd419e61)