FN-7787: honor assigned agent's runtimeConfig model in task execution sessions
Task execution sessions previously ignored the assigned permanent agent's runtimeConfig model whenever the executor was handed an agents-less worktree AgentStore, silently drifting to the pi runtime's built-in default model instead of the configured one. - Add TaskExecutor.getAuthoritativeAssignedAgent(): falls back to the authoritative project `.fusion` AgentStore when the live executor's worktree AgentStore has no record of the assigned agent, so runtimeConfig resolution matches chat-session behavior. - Replace direct `this.options.agentStore.getAgent(...)` lookups across step-session, workflow-graph, and legacy execution paths with the new authoritative lookup helper. - Warn and audit (`noModelResolved` / `runtimeBuiltInFallbackModel`) when a non-mock, non-test-mode session resolves no provider/model pair and falls back to the runtime's built-in default, so the drift is visible instead of silent. - Add regression tests covering assigned-agent runtime-config resolution and the new runtime-resolved audit fields. - Add changeset (patch) and update docs/settings-reference.md and AGENTS.md. Files changed: .changeset/fuzzy-fable-fallback.md | 7 +++ AGENTS.md | 1 + docs/settings-reference.md | 2 +- .../executor-assigned-agent-runtime-config.test.ts | 68 ++++++++++++++++++++++ .../run-audit-session-runtime-resolved.test.ts | 44 ++++++++++++++ packages/engine/src/agent-session-helpers.ts | 31 +++++++--- packages/engine/src/executor.ts | 43 +++++++++----- 7 files changed, 174 insertions(+), 22 deletions(-) Fusion-Task-Id: FN-7787 Fusion-Task-Lineage: 40fccad5-2e67-4ee2-8199-4548ce9025c6 Co-authored-by: Fusion (runfusion.ai) <noreply@runfusion.ai>
This commit is contained in:
7
.changeset/fuzzy-fable-fallback.md
Normal file
7
.changeset/fuzzy-fable-fallback.md
Normal file
@@ -0,0 +1,7 @@
|
||||
---
|
||||
"@runfusion/fusion": patch
|
||||
---
|
||||
|
||||
summary: Honor assigned agent models in execution and warn on default-model fallbacks.
|
||||
category: fix
|
||||
dev: Executor assigned-agent lookup now falls back to the root AgentStore; session audit adds noModelResolved/runtimeBuiltInFallbackModel when resolution is empty.
|
||||
@@ -223,6 +223,7 @@ Scoped exception (FN-5819): shared-branch-group members (`branchContext.assignme
|
||||
|
||||
- FN-7158: agent performance reflections emit `reflection:generated`, `reflection:skipped`, and `reflection:failed` with ids/counts/outcomes-only metadata; never persist reflection prose or prompt text in run-audit.
|
||||
- FN-7528: a deterministic, non-LLM post-task performance capture (`AgentReflectionService.captureTaskPerformance`) runs once per completed task and emits `reflection:captured` with ids/counts/outcomes-only metadata (`retryReworkCount?`, `filesTouchedCount?`, `packagesTouchedCount?`, `verificationFileScoped?`, `durationMs?`); never persists `verificationScopeReason` free-text or summary prose in run-audit.
|
||||
- FN-7787: `createResolvedAgentSession` enriches `session:runtime-resolved` with `noModelResolved: true` and `runtimeBuiltInFallbackModel` when a non-mock/non-test session reaches runtime creation without a complete provider/model pair; this is a visibility signal for runtime built-in fallback usage, not a fabricated model-resolution verdict.
|
||||
- FN-7011: self-healing emits `task:reconcile-engine-downtime-active-timing` when startup recovery shifts active task segment anchors to exclude proven engine-process downtime, and `task:reconcile-engine-downtime-active-timing-no-action` when no active task qualifies.
|
||||
- FN-5419: git run-audit now includes `pull:fast-forward` and `stash:pop-conflict`; dashboard git surfaces now include the extended `POST /api/git/pull` integration-worktree path plus companion `POST /api/git/stash-resolve`, `POST /api/git/stash-drop`, and `POST /api/git/stash-apply` routes.
|
||||
- FN-6292: self-healing emits `task:reconcile-dependency-blocking-lease` when it rebounds an in-progress holder whose stale file-scope lease blocks an unmet dependency, and `task:reconcile-dependency-blocking-lease-no-action` when triple-proof blocks that backward move.
|
||||
|
||||
@@ -1006,7 +1006,7 @@ The three GPT-5.6 codenamed OpenAI Codex variants (`gpt-5.6-luna`, `gpt-5.6-sol`
|
||||
6. Assigned durable agent runtime model (`runtimeConfig.model` or `runtimeConfig.modelProvider` + `runtimeConfig.modelId`) when both provider and model ID are set and no task/lane/default pair is configured
|
||||
7. Automatic provider/model resolution
|
||||
|
||||
Workflow prompt steps and scheduled/manual AI-prompt automation steps use the same executor lane before falling back to project/global defaults; explicit step-level `modelProvider` + `modelId` values still take precedence for that individual step.
|
||||
Workflow prompt steps and scheduled/manual AI-prompt automation steps use the same executor lane before falling back to project/global defaults; explicit step-level `modelProvider` + `modelId` values still take precedence for that individual step. If a non-mock, non-test-mode session still reaches runtime creation without a complete provider/model pair, Fusion logs a warning and records `noModelResolved` plus `runtimeBuiltInFallbackModel` on `session:runtime-resolved` so the runtime's built-in fallback model is observable.
|
||||
|
||||
### Heartbeat model (durable agents)
|
||||
|
||||
|
||||
@@ -0,0 +1,68 @@
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { AgentStore } from "@fusion/core";
|
||||
import { TaskExecutor } from "../executor.js";
|
||||
import { resolveExecutorSessionModel } from "../agent-session-helpers.js";
|
||||
|
||||
function createStore() {
|
||||
return {
|
||||
on: vi.fn(),
|
||||
getFusionDir: vi.fn(() => "/worktree/.fusion"),
|
||||
} as any;
|
||||
}
|
||||
|
||||
describe("TaskExecutor assigned-agent runtimeConfig lookup", () => {
|
||||
const roots: string[] = [];
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(roots.splice(0).map((root) => rm(root, { recursive: true, force: true })));
|
||||
});
|
||||
|
||||
async function createHarness(runtimeConfig: Record<string, unknown> | undefined) {
|
||||
const rootDir = await mkdtemp(join(tmpdir(), "fn-7787-"));
|
||||
roots.push(rootDir);
|
||||
const authoritative = new AgentStore({ rootDir: join(rootDir, ".fusion") });
|
||||
await authoritative.init();
|
||||
const agent = await authoritative.createAgent({
|
||||
name: `executor-${Math.random().toString(16).slice(2)}`,
|
||||
role: "executor",
|
||||
...(runtimeConfig ? { runtimeConfig } : {}),
|
||||
});
|
||||
const worktreeAgentStore = { getAgent: vi.fn().mockResolvedValue(null) };
|
||||
const executor = new TaskExecutor(createStore(), rootDir, { agentStore: worktreeAgentStore } as any);
|
||||
return { executor: executor as any, agent, worktreeAgentStore };
|
||||
}
|
||||
|
||||
it.each([
|
||||
[{ model: "anthropic/claude-fable-5", modelProvider: "ignored", modelId: "ignored" }, { provider: "anthropic", modelId: "claude-fable-5" }],
|
||||
[{ modelProvider: "anthropic", modelId: "claude-fable-5" }, { provider: "anthropic", modelId: "claude-fable-5" }],
|
||||
])("falls back from an agents-less execution store to the authoritative project agent runtimeConfig %#", async (runtimeConfig, expected) => {
|
||||
const { executor, agent, worktreeAgentStore } = await createHarness(runtimeConfig);
|
||||
|
||||
const foundRuntimeConfig = await executor.getAssignedAgentRuntimeConfig(agent.id);
|
||||
|
||||
expect(worktreeAgentStore.getAgent).toHaveBeenCalledWith(agent.id);
|
||||
expect(resolveExecutorSessionModel(undefined, undefined, {}, foundRuntimeConfig)).toEqual(expected);
|
||||
});
|
||||
|
||||
it("returns undefined when the assigned agent is missing or has no complete runtime model", async () => {
|
||||
const { executor, agent } = await createHarness(undefined);
|
||||
|
||||
expect(await executor.getAssignedAgentRuntimeConfig("missing-agent")).toBeUndefined();
|
||||
const runtimeConfig = await executor.getAssignedAgentRuntimeConfig(agent.id);
|
||||
expect(resolveExecutorSessionModel(undefined, undefined, {}, runtimeConfig)).toEqual({ provider: undefined, modelId: undefined });
|
||||
});
|
||||
|
||||
it("keeps configured settings ahead of the assigned agent runtimeConfig", async () => {
|
||||
const { executor, agent } = await createHarness({ model: "anthropic/claude-fable-5" });
|
||||
|
||||
const runtimeConfig = await executor.getAssignedAgentRuntimeConfig(agent.id);
|
||||
|
||||
expect(resolveExecutorSessionModel(undefined, undefined, {
|
||||
executionProvider: "openai",
|
||||
executionModelId: "gpt-4.1",
|
||||
}, runtimeConfig)).toEqual({ provider: "openai", modelId: "gpt-4.1" });
|
||||
});
|
||||
});
|
||||
@@ -73,4 +73,48 @@ describe("FN-5544 session:runtime-resolved audit event", () => {
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.metadata).toEqual(expect.objectContaining({ sessionPurpose: "heartbeat", runtimeHint: "hermes", testModeActive: true }));
|
||||
});
|
||||
|
||||
it("warns and audits the runtime built-in fallback when no non-mock model resolves", async () => {
|
||||
resolveRuntimeMock.mockResolvedValue({
|
||||
runtime: {
|
||||
id: "pi",
|
||||
name: "pi",
|
||||
createSession: vi.fn().mockResolvedValue({ session: { model: { provider: "anthropic", id: "claude-opus-4-8" }, prompt: vi.fn() } }),
|
||||
promptWithFallback: vi.fn(),
|
||||
describeModel: vi.fn(() => "anthropic/claude-opus-4-8"),
|
||||
},
|
||||
runtimeId: "pi",
|
||||
wasConfigured: false,
|
||||
});
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const auditor = createRunAuditor(store, { runId: "r5", agentId: "a1", taskId: "FN-7787", phase: "execute", source: "executor" });
|
||||
|
||||
await createResolvedAgentSession({ sessionPurpose: "executor", cwd: "/tmp/project", systemPrompt: "system", runAuditor: auditor, settings: {} as any });
|
||||
|
||||
expect(warnSpy).toHaveBeenCalledWith(expect.stringContaining("built-in fallback model \"anthropic/claude-opus-4-8\""));
|
||||
const events = store.getRunAuditEvents({ mutationType: "session:runtime-resolved" });
|
||||
expect(events).toHaveLength(1);
|
||||
expect(events[0]?.metadata).toEqual(expect.objectContaining({
|
||||
provider: null,
|
||||
modelId: null,
|
||||
noModelResolved: true,
|
||||
runtimeBuiltInFallbackModel: "anthropic/claude-opus-4-8",
|
||||
}));
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
|
||||
it("does not warn about built-in fallback for mock, test-mode, or fully resolved sessions", async () => {
|
||||
const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
|
||||
const auditor = createRunAuditor(store, { runId: "r6", agentId: "a1", taskId: "FN-7787", phase: "execute", source: "executor" });
|
||||
|
||||
await createResolvedAgentSession({ sessionPurpose: "executor", cwd: "/tmp/project", systemPrompt: "system", defaultProvider: MOCK_PROVIDER_ID, defaultModelId: "scripted", runAuditor: auditor });
|
||||
await createResolvedAgentSession({ sessionPurpose: "executor", cwd: "/tmp/project", systemPrompt: "system", runAuditor: auditor, settings: { testMode: true } as any });
|
||||
await createResolvedAgentSession({ sessionPurpose: "executor", cwd: "/tmp/project", systemPrompt: "system", defaultProvider: "anthropic", defaultModelId: "claude-fable-5", runAuditor: auditor });
|
||||
|
||||
expect(warnSpy).not.toHaveBeenCalledWith(expect.stringContaining("no complete provider/model resolved"));
|
||||
const events = store.getRunAuditEvents({ mutationType: "session:runtime-resolved" });
|
||||
expect(events).toHaveLength(3);
|
||||
expect(events.every((event) => !(event.metadata as Record<string, unknown>).noModelResolved)).toBe(true);
|
||||
warnSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -526,6 +526,26 @@ export async function createResolvedAgentSession(
|
||||
`[${sessionPurpose}] Using runtime "${resolved.runtimeId}" (configured=${resolved.wasConfigured})`,
|
||||
);
|
||||
|
||||
// Forward `beforeSpawnSession` to the runtime so it fires at the true
|
||||
// latest sync point (just before LLM session instantiation) rather than
|
||||
// here, before the runtime's own awaited setup work runs. See
|
||||
// AgentRuntimeOptions.beforeSpawnSession for the contract.
|
||||
const result = await resolved.runtime.createSession(effectiveRuntimeOptionsWithModel);
|
||||
|
||||
const testModeActive = settings ? isTestModeActive(settings) : false;
|
||||
const mockProviderActive = isMockProviderId(runtimeOptions.defaultProvider);
|
||||
const noModelResolved = !mockProviderActive && !testModeActive && (!runtimeOptions.defaultProvider || !runtimeOptions.defaultModelId);
|
||||
const runtimeBuiltInFallbackModel = noModelResolved ? resolved.runtime.describeModel(result.session) : undefined;
|
||||
if (noModelResolved) {
|
||||
/*
|
||||
FNXC:ModelResolution 2026-07-10-00:00:
|
||||
Fusion#1984 showed that non-mock/non-test task sessions could resolve no provider+model pair and then quietly run the pi runtime's built-in default, creating unexpected spend. Keep the fallback non-fatal for existing default-model deployments, but warn and audit the actual runtime model so the drift is visible.
|
||||
*/
|
||||
sessionLog.warn(
|
||||
`[${sessionPurpose}] no complete provider/model resolved; runtime "${resolved.runtimeId}" is using built-in fallback model "${runtimeBuiltInFallbackModel ?? "unknown model"}"`,
|
||||
);
|
||||
}
|
||||
|
||||
try {
|
||||
await runAuditor?.database({
|
||||
type: "session:runtime-resolved",
|
||||
@@ -536,8 +556,9 @@ export async function createResolvedAgentSession(
|
||||
wasConfigured: resolved.wasConfigured,
|
||||
provider: runtimeOptions.defaultProvider ?? null,
|
||||
modelId: runtimeOptions.defaultModelId ?? null,
|
||||
mockProviderActive: isMockProviderId(runtimeOptions.defaultProvider),
|
||||
testModeActive: settings ? isTestModeActive(settings) : false,
|
||||
mockProviderActive,
|
||||
testModeActive,
|
||||
...(noModelResolved ? { noModelResolved: true, runtimeBuiltInFallbackModel } : {}),
|
||||
...(effectiveRuntimeHint ? { runtimeHint: effectiveRuntimeHint } : {}),
|
||||
...(autoGrokRuntimeHint ? { reason: "grok-cli-no-visible-key" } : {}),
|
||||
...(!autoGrokRuntimeHint && "fallbackReason" in resolved && resolved.fallbackReason ? { reason: resolved.fallbackReason } : {}),
|
||||
@@ -547,12 +568,6 @@ export async function createResolvedAgentSession(
|
||||
sessionLog.warn(`[${sessionPurpose}] failed to record session:runtime-resolved audit: ${String(err)}`);
|
||||
}
|
||||
|
||||
// Forward `beforeSpawnSession` to the runtime so it fires at the true
|
||||
// latest sync point (just before LLM session instantiation) rather than
|
||||
// here, before the runtime's own awaited setup work runs. See
|
||||
// AgentRuntimeOptions.beforeSpawnSession for the contract.
|
||||
const result = await resolved.runtime.createSession(effectiveRuntimeOptionsWithModel);
|
||||
|
||||
// Attach the resolved runtime's promptWithFallback as a bound method on the
|
||||
// session object when it is not already present. This is the dispatch hook
|
||||
// that pi.promptWithFallback (pi.ts:175) checks before falling through to its
|
||||
|
||||
@@ -13,7 +13,7 @@ import { existsSync, lstatSync, realpathSync } from "node:fs";
|
||||
import { readFile, rm, writeFile } from "node:fs/promises";
|
||||
import type { TaskStore, Task, TaskDetail, TaskTokenUsage, StepStatus, Settings, WorkflowStep, MissionStore, Slice, AgentState, AgentCapability, RunMutationContext, AgentHeartbeatConfig, Agent, AgentMemoryInclusionMode, ProjectSettings, MergeResult, WorkflowIrNode, WorkflowIrNodeKind, WorkflowStepResult as CoreWorkflowStepResult, ThinkingLevel } from "@fusion/core";
|
||||
import { getUnmetSchedulingDependencies } from "./scheduler.js";
|
||||
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS } from "@fusion/core";
|
||||
import { RetryStormError, TaskDeletedError, serializeRetryStormError, isExperimentalFeatureEnabled, resolveWorkflowIrForTask, resolveColumnAgentBinding, resolveEffectiveAgent, instanceNodeId, getWorkflowExtensionRegistry, getBuiltinWorkflow, parseNoOpCompletionMarker, allowsAutoMergeProcessing, resolveEffectiveAutoMerge, isLiveSharedBranchGroupMemberIntegration, resolveMaxAutoMergeRetries, resolveOptionalStepRevisionBudget, resolveOptionalReviewRevisionBudget, COMPLETION_SUMMARY_NODE_ID, upsertWorkflowStepResult, AWAITING_APPROVAL_PAUSE_REASON, THINKING_LEVELS, AgentStore } from "@fusion/core";
|
||||
import { finalizeProvenAutoMergeTask } from "./auto-merge-finalization.js";
|
||||
import { mergeEffectiveSettings } from "./effective-settings.js";
|
||||
import type { TaskStep, WorkflowIr, WorkflowFieldDefinition, WorkflowColumnAgent, EffectiveAgentInput, WorkflowWorkEngineDispatchResult } from "@fusion/core";
|
||||
@@ -1722,6 +1722,8 @@ export class TaskExecutor {
|
||||
private activeWorkflowStepSessionSeenSteeringIds = new Map<string, Set<string>>();
|
||||
/** Active configured-command abort controllers keyed by task. */
|
||||
private activeConfiguredCommandControllers = new Map<string, Set<AbortController>>();
|
||||
/** Lazily-created root-project reader used only when an execution lookup is handed an agents-less worktree store. */
|
||||
private authoritativeAssignedAgentStore: AgentStore | null = null;
|
||||
/** Active workflow-graph runner abort controllers keyed by task. */
|
||||
private activeWorkflowGraphAbortControllers = new Map<string, AbortController>();
|
||||
/**
|
||||
@@ -4492,12 +4494,33 @@ export class TaskExecutor {
|
||||
return activeRun !== null;
|
||||
}
|
||||
|
||||
private async getAuthoritativeAssignedAgent(
|
||||
assignedAgentId: string | null | undefined,
|
||||
): Promise<Agent | null> {
|
||||
const normalizedId = assignedAgentId?.trim();
|
||||
if (!normalizedId) return null;
|
||||
|
||||
const configuredAgent = await this.options.agentStore?.getAgent(normalizedId).catch(() => null) ?? null;
|
||||
if (configuredAgent) return configuredAgent;
|
||||
|
||||
/*
|
||||
FNXC:ModelResolution 2026-07-10-00:00:
|
||||
Task execution sessions must honor the assigned permanent agent's runtimeConfig like chat sessions do. If the live executor was handed an agents-less worktree AgentStore, fall back to the authoritative project `.fusion` AgentStore instead of letting `resolveExecutorSessionModel` see an empty runtimeConfig and silently drift to the pi built-in model.
|
||||
*/
|
||||
try {
|
||||
this.authoritativeAssignedAgentStore ??= new AgentStore({ rootDir: join(this.rootDir, ".fusion"), taskStore: this.store });
|
||||
await this.authoritativeAssignedAgentStore.init();
|
||||
return await this.authoritativeAssignedAgentStore.getAgent(normalizedId).catch(() => null);
|
||||
} catch (err: unknown) {
|
||||
executorLog.warn(`Failed to read assigned agent ${normalizedId} from authoritative project AgentStore: ${err instanceof Error ? err.message : String(err)}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
private async getAssignedAgentRuntimeConfig(
|
||||
assignedAgentId: string | null | undefined,
|
||||
): Promise<Record<string, unknown> | undefined> {
|
||||
const normalizedId = assignedAgentId?.trim();
|
||||
if (!normalizedId || !this.options.agentStore) return undefined;
|
||||
const agent = await this.options.agentStore.getAgent(normalizedId).catch(() => null);
|
||||
const agent = await this.getAuthoritativeAssignedAgent(assignedAgentId);
|
||||
return (agent?.runtimeConfig ?? undefined) as Record<string, unknown> | undefined;
|
||||
}
|
||||
|
||||
@@ -9829,9 +9852,7 @@ export class TaskExecutor {
|
||||
// ── Step-Session Path ──────────────────────────────────────────
|
||||
executorLog.log(`${task.id}: using step-session mode (maxParallel=${settings.maxParallelSteps ?? 2}${forceStepSession ? ", graph-pinned" : ""})`);
|
||||
|
||||
const stepSessionAgent = detail.assignedAgentId && this.options.agentStore
|
||||
? await this.options.agentStore.getAgent(detail.assignedAgentId).catch(() => null)
|
||||
: null;
|
||||
const stepSessionAgent = await this.getAuthoritativeAssignedAgent(detail.assignedAgentId);
|
||||
|
||||
// Column-agent SESSION IDENTITY (U4, R2/R3/R4/R8): when the governing
|
||||
// step-execute node's declared column binds an agent that supersedes the
|
||||
@@ -10402,9 +10423,7 @@ export class TaskExecutor {
|
||||
const reflectionTools = this.options.reflectionService && settings.reflectionEnabled && assignedAgentId
|
||||
? [createReflectOnPerformanceTool(this.options.reflectionService, assignedAgentId)]
|
||||
: [];
|
||||
const assignedAgent = assignedAgentId && this.options.agentStore
|
||||
? await this.options.agentStore.getAgent(assignedAgentId).catch(() => null)
|
||||
: null;
|
||||
const assignedAgent = await this.getAuthoritativeAssignedAgent(assignedAgentId);
|
||||
|
||||
// Column-agent SESSION IDENTITY (U4, R2/R3/R4/R8): when the governing execute
|
||||
// seam node's declared column binds an agent that supersedes the task's
|
||||
@@ -15006,9 +15025,7 @@ You have access to the file system to review changes.${inlineFixBlock}${verdictB
|
||||
pluginRunner: this.options.pluginRunner,
|
||||
});
|
||||
|
||||
const workflowAgent = task.assignedAgentId && this.options.agentStore
|
||||
? await this.options.agentStore.getAgent(task.assignedAgentId).catch(() => null)
|
||||
: null;
|
||||
const workflowAgent = await this.getAuthoritativeAssignedAgent(task.assignedAgentId);
|
||||
const workflowRuntimeHint = extractRuntimeHint(workflowAgent?.runtimeConfig);
|
||||
// Signal to skills running in this step (e.g. compound-engineering ce-plan /
|
||||
// ce-work) that they are inside a Fusion autonomous workflow step, NOT an
|
||||
|
||||
Reference in New Issue
Block a user