diff --git a/apps/backend/alembic/versions/0047_scan_active_index_per_ref.py b/apps/backend/alembic/versions/0047_scan_active_index_per_ref.py new file mode 100644 index 0000000..18be1d2 --- /dev/null +++ b/apps/backend/alembic/versions/0047_scan_active_index_per_ref.py @@ -0,0 +1,81 @@ +# SPDX-License-Identifier: Apache-2.0 +# Copyright 2026 TRUSCA contributors +"""allow one in-flight scan per branch instead of one per project + +Revision ID: 0047 +Revises: 0046 +Create Date: 2026-08-04 + +Phase: branch as a first-class axis (follows 0046) +Kind: schema (replaces one partial unique index; no data migration) +Forward-only: yes + +What: + ``ix_scans_project_active`` is rebuilt on ``(project_id, ref)`` with + ``NULLS NOT DISTINCT``, keeping its ``status IN ('queued','running')`` + predicate. + +Why: + The index enforced at most one in-flight scan per PROJECT. Once branches + became a first-class axis — the current-state anchor prefers the main line, + the reads filter by ref — that limit stopped matching the model: pushing to + ``main`` and ``release/1.x`` at the same time made one CI job wait for the + other's scan, or fail with a 409 it could do nothing about. The branches + write to disjoint snapshots, so nothing about the data required them to be + serialized. + +Why NULLS NOT DISTINCT: + ``ref`` is NULL for ad-hoc scans, which is the majority of rows. Under the + default NULLS DISTINCT a plain ``(project_id, ref)`` unique index would stop + constraining them at all — every manual re-trigger would queue another scan, + turning a stability guard into nothing for the common case. NULLS NOT + DISTINCT (PostgreSQL 15+; we pin 17) makes all ref-less rows of a project + collide with each other, so ad-hoc scans keep exactly today's behaviour and + only *named branches* gain concurrency. + +Blast radius: + A project can now hold as many in-flight scans as it has distinct refs. The + bound that matters is the per-team cap (``_enforce_team_concurrency_cap``) + and the per-user trigger rate limit, both unchanged; the disk guard still + refuses new work when the workspace is full. + +Locking / build strategy: + DROP then CREATE inside the migration transaction. The guard is absent for + that window, which is safe because ``scripts/upgrade.sh`` stops the app + containers before migrating — nothing can trigger a scan meanwhile. Doing it + the other way (create first, drop after) is impossible: the two indexes would + both have to hold, and the old one is exactly what we are relaxing. + +Migration policy (CLAUDE.md §6): + - No column changes, no backfill, no data migration. + - Forward-only: ``downgrade()`` raises ``NotImplementedError``. +""" + +from __future__ import annotations + +from collections.abc import Sequence + +from alembic import op + +revision: str = "0047" +down_revision: str | None = "0046" +branch_labels: str | Sequence[str] | None = None +depends_on: str | Sequence[str] | None = None + + +def upgrade() -> None: + op.execute("DROP INDEX IF EXISTS ix_scans_project_active") + # Mirrors the Index(...) declaration in models.scan.Scan.__table_args__ — + # keep the two in step or ``alembic check`` reports the schema as drifted. + op.execute( + """ + CREATE UNIQUE INDEX ix_scans_project_active + ON scans (project_id, ref) + NULLS NOT DISTINCT + WHERE status IN ('queued','running') + """ + ) + + +def downgrade() -> None: + raise NotImplementedError("forward-only migration (CLAUDE.md §6)") diff --git a/apps/backend/models/scan.py b/apps/backend/models/scan.py index 2436f1f..b1a7fc9 100644 --- a/apps/backend/models/scan.py +++ b/apps/backend/models/scan.py @@ -381,13 +381,20 @@ class Scan(Base): Index("ix_scans_celery_task_id", "celery_task_id"), # JSONB GIN — supports `metadata @> '{...}'` (e.g. "find scans of branch X"). Index("ix_scans_metadata_gin", "metadata", postgresql_using="gin"), - # Concurrency gate: at most one scan per project may be queued or - # running at any time. Mirrored in the migration via op.execute() - # because partial unique indexes need explicit DDL. + # Concurrency gate: at most one in-flight scan per (project, branch). + # Two branches write disjoint snapshots, so serializing them only made + # one CI job wait on another's push. NULLS NOT DISTINCT is what keeps + # this a guard rather than a formality: ``ref`` is NULL for ad-hoc + # scans (most rows), and under the default NULLS DISTINCT those would + # stop colliding entirely and a project could queue unbounded manual + # re-triggers. Migration 0047 mirrors this via op.execute() — partial + # unique indexes need explicit DDL. Index( "ix_scans_project_active", "project_id", + "ref", unique=True, + postgresql_nulls_not_distinct=True, postgresql_where=text("status IN ('queued','running')"), ), # W6-#42 — rematch beat's "due succeeded scans" hot path. Partial on diff --git a/apps/backend/schemas/project_detail.py b/apps/backend/schemas/project_detail.py index 43ee81d..144f1e1 100644 --- a/apps/backend/schemas/project_detail.py +++ b/apps/backend/schemas/project_detail.py @@ -86,6 +86,16 @@ class ScanSummary(BaseModel): "renders). ``null`` when the scan was run without a release label." ), ) + ref: str | None = Field( + default=None, + description=( + "Normalized git ref this scan targeted (``main``, ``pr-12``), or " + "``null`` for an ad-hoc trigger that carried none. The concurrency " + "gate is per-(project, ref), so a client deciding whether its own " + "trigger would conflict has to compare against this, not merely " + "against 'some scan is active'." + ), + ) @model_validator(mode="before") @classmethod diff --git a/apps/backend/services/scan_service.py b/apps/backend/services/scan_service.py index ec4c76b..42c5030 100644 --- a/apps/backend/services/scan_service.py +++ b/apps/backend/services/scan_service.py @@ -9,10 +9,12 @@ inside `trigger_scan` flags the exact insertion point. Concurrency contract (CLAUDE.md core rule #3 + models/scan.py partial unique -index `ix_scans_project_active`): at most one scan per project may be in -state queued|running. The DB rejects a second INSERT with IntegrityError; we -translate that to `ScanInProgressConflict` (409) so callers get a stable RFC -7807 envelope instead of a Python traceback. +index `ix_scans_project_active`): at most one scan per (project, branch) may +be in state queued|running, where all ref-less ad-hoc scans of a project count +as one branch. Two branches write disjoint snapshots, so they run in parallel; +re-triggering the same branch still conflicts. The DB rejects the second +INSERT with IntegrityError; we translate that to `ScanInProgressConflict` (409) +so callers get a stable RFC 7807 envelope instead of a Python traceback. """ from __future__ import annotations @@ -108,6 +110,19 @@ class ScanInProgressConflict(ScanError): title = "Scan Already In Progress" +def _in_progress_detail(project_id: object, ref: str | None) -> str: + """Name the branch that is busy, not just the project. + + Since the concurrency gate became per-(project, ref), "a scan is already + running for this project" would send the caller looking for a conflict that + may be on a branch they are not touching. The ref-less case keeps the old + wording because there is no branch to name. + """ + if ref: + return f"a scan is already queued or running for {ref} in project {project_id}" + return f"a scan is already queued or running for project {project_id}" + + class ScanArchivedConflict(ScanError): """409 — the project is archived; archiving disables new scans (H-7). @@ -336,8 +351,8 @@ async def _enforce_team_concurrency_cap( """Raise :class:`ConcurrentScanLimitExceeded` if the team is at the cap. A cap of 0 (or negative) disables the check entirely — the operator has - opted out and only the per-project unique index + per-user rate limit - apply. + opted out and only the per-(project, branch) unique index + per-user rate + limit apply. Note (race window — soft cap): this SELECT-then-INSERT is not atomic across concurrent triggers from the same team. N requests can each read @@ -347,9 +362,10 @@ async def _enforce_team_concurrency_cap( M2 (security review): worst-case bound. The overshoot is bounded, not unbounded, by two independent controls: - * the per-project unique partial index (``ix_scans_project_active``) - guarantees at most ONE active scan per project, so a single project - can never contribute more than 1 to the overshoot; and + * the unique partial index (``ix_scans_project_active``) guarantees at + most ONE active scan per (project, branch), so a project contributes at + most one per branch it is being pushed to rather than an unbounded + number of re-triggers; and * the per-user scan-trigger rate limit (``SCAN_TRIGGER_RATE_LIMIT``, default 20/min) bounds how many triggers any one member can fire in the race window. @@ -708,7 +724,7 @@ async def trigger_scan( try: await session.flush() except IntegrityError as exc: - # The partial unique index on (project_id) WHERE status IN + # The partial unique index on (project_id, ref) WHERE status IN # ('queued','running') is the canonical signal. Postgres returns the # constraint name in the orig message; we don't switch on it because # the only realistic constraint that fires from this INSERT is the @@ -716,7 +732,7 @@ async def trigger_scan( # exists. await session.rollback() raise ScanInProgressConflict( - f"a scan is already queued or running for project {project_id_value}", + _in_progress_detail(project_id_value, scan.ref), ) from exc # I-2: keep the project.latest_scan_id pointer in sync so list pages @@ -735,7 +751,7 @@ async def trigger_scan( # possible if the txn was held briefly). Translate identically. await session.rollback() raise ScanInProgressConflict( - f"a scan is already queued or running for project {project_id_value}", + _in_progress_detail(project_id_value, scan.ref), ) from exc await session.refresh(scan) diff --git a/apps/backend/tests/integration/test_scans_api.py b/apps/backend/tests/integration/test_scans_api.py index be8bccb..352e504 100644 --- a/apps/backend/tests/integration/test_scans_api.py +++ b/apps/backend/tests/integration/test_scans_api.py @@ -368,6 +368,92 @@ async def test_concurrent_trigger_returns_409_problem(client) -> None: assert body.get("scan_already_in_progress") is True +async def test_two_branches_scan_concurrently(client) -> None: + """The gate is per-(project, branch): disjoint branches must not serialize.""" + team, user, project = await _seed(client, role="developer") + headers = _bearer_for(user) + + first = await client.post( + f"/v1/projects/{project.id}/scans", + headers=headers, + json={"kind": "source", "metadata": {"ref": "refs/heads/main"}}, + ) + assert first.status_code == 202, first.text + + second = await client.post( + f"/v1/projects/{project.id}/scans", + headers=headers, + json={"kind": "source", "metadata": {"ref": "refs/heads/release/1.x"}}, + ) + assert second.status_code == 202, second.text + assert first.json()["ref"] == "main" + assert second.json()["ref"] == "release/1.x" + + +async def test_same_branch_retrigger_still_conflicts(client) -> None: + # Relaxing the gate must not make it a formality — the same branch twice is + # still duplicate work on the same snapshot. + team, user, project = await _seed(client, role="developer") + headers = _bearer_for(user) + + first = await client.post( + f"/v1/projects/{project.id}/scans", + headers=headers, + json={"kind": "source", "metadata": {"ref": "refs/heads/main"}}, + ) + assert first.status_code == 202, first.text + + # A bare branch name must collide with the fully-qualified form the first + # trigger used — both normalize to `main`. + second = await client.post( + f"/v1/projects/{project.id}/scans", + headers=headers, + json={"kind": "source", "metadata": {"ref": "main"}}, + ) + assert second.status_code == 409, second.text + body = second.json() + assert body.get("scan_already_in_progress") is True + # The detail names the busy branch so the caller does not go looking for a + # conflict on a branch they never touched. + assert "main" in body["detail"] + + +async def test_adhoc_scans_still_serialize_with_each_other(client) -> None: + # ref is NULL for ad-hoc triggers. Under the default NULLS DISTINCT the + # relaxed index would stop constraining them at all, so this pins the + # NULLS NOT DISTINCT half of the migration. + team, user, project = await _seed(client, role="developer") + headers = _bearer_for(user) + + first = await client.post( + f"/v1/projects/{project.id}/scans", headers=headers, json={"kind": "source"} + ) + assert first.status_code == 202, first.text + + second = await client.post( + f"/v1/projects/{project.id}/scans", headers=headers, json={"kind": "source"} + ) + assert second.status_code == 409, second.text + assert second.json().get("scan_already_in_progress") is True + + +async def test_adhoc_scan_does_not_block_a_branch_scan(client) -> None: + team, user, project = await _seed(client, role="developer") + headers = _bearer_for(user) + + adhoc = await client.post( + f"/v1/projects/{project.id}/scans", headers=headers, json={"kind": "source"} + ) + assert adhoc.status_code == 202, adhoc.text + + branch = await client.post( + f"/v1/projects/{project.id}/scans", + headers=headers, + json={"kind": "source", "metadata": {"ref": "refs/heads/main"}}, + ) + assert branch.status_code == 202, branch.text + + async def test_trigger_on_archived_project_returns_409(client) -> None: """H-7: an archived project must reject new scans, not silently accept them.""" from datetime import UTC, datetime diff --git a/apps/frontend/src/features/projects/ProjectDetailPage.tsx b/apps/frontend/src/features/projects/ProjectDetailPage.tsx index 9820648..2159d59 100644 --- a/apps/frontend/src/features/projects/ProjectDetailPage.tsx +++ b/apps/frontend/src/features/projects/ProjectDetailPage.tsx @@ -270,6 +270,15 @@ export function ProjectDetailPage() { const activeScan = (overview.data?.recent_scans ?? []).find( (scan) => scan.status === "queued" || scan.status === "running", ); + // The concurrency gate is per-(project, branch), and the Scan button always + // triggers an ad-hoc (ref-less) run — so only another ad-hoc scan can block + // it. A CI scan on `main` leaves the button usable; disabling on "any active + // scan" would hide the concurrency the gate now allows. + const blockingScan = (overview.data?.recent_scans ?? []).find( + (scan) => + (scan.status === "queued" || scan.status === "running") && + scan.ref == null, + ); if (!projectId) { return ( @@ -493,6 +502,7 @@ export function ProjectDetailPage() { demoReadOnly={writesDisabled} onScan={() => setSourceDialogOpen(true)} activeScan={activeScan ?? null} + blockingScan={blockingScan ?? null} onReopenActiveScan={ activeScan ? () => handleReopenScan(activeScan) : undefined } @@ -768,6 +778,8 @@ interface ProjectDetailHeaderProps { * progress drawer never strands the user. */ activeScan: ScanSummary | null; + /** The active ad-hoc scan that would actually conflict with the button. */ + blockingScan: ScanSummary | null; /** Re-open the live progress drawer for {@link activeScan}. */ onReopenActiveScan?: () => void; /** Currently pinned scan id (`?scan=`), or undefined for the live view. */ @@ -792,6 +804,7 @@ function ProjectDetailHeader({ demoReadOnly, onScan, activeScan, + blockingScan, onReopenActiveScan, pinnedScanId, latestScanId, @@ -905,11 +918,11 @@ function ProjectDetailHeader({ diff --git a/apps/frontend/src/features/projects/api/projectDetailApi.ts b/apps/frontend/src/features/projects/api/projectDetailApi.ts index 49c0192..b09fa83 100644 --- a/apps/frontend/src/features/projects/api/projectDetailApi.ts +++ b/apps/frontend/src/features/projects/api/projectDetailApi.ts @@ -61,6 +61,12 @@ export interface ScanSummary { * without a release label. */ release: string | null; + /** + * Normalized git ref the scan targeted, or `null` for an ad-hoc trigger. + * The concurrency gate is per-(project, ref), so "is my trigger blocked?" + * has to compare refs rather than just checking for any active scan. + */ + ref: string | null; } export interface ProjectOverviewResponse { diff --git a/apps/frontend/tests/unit/ProjectDetailPage.test.tsx b/apps/frontend/tests/unit/ProjectDetailPage.test.tsx index 0830d71..519bc17 100644 --- a/apps/frontend/tests/unit/ProjectDetailPage.test.tsx +++ b/apps/frontend/tests/unit/ProjectDetailPage.test.tsx @@ -99,7 +99,7 @@ function makeProject(): ProjectPublic { }; } -function scan(status: string, id: string): ScanSummary { +function scan(status: string, id: string, ref: string | null = null): ScanSummary { return { id, kind: "source", @@ -109,6 +109,7 @@ function scan(status: string, id: string): ScanSummary { completed_at: null, created_at: "2026-05-26T00:00:00Z", release: null, + ref, }; } @@ -196,4 +197,26 @@ describe("ProjectDetailPage active-scan chip (#29)", () => { // …but no active-scan chip, because nothing is queued/running. expect(screen.queryByTestId("project-detail-active-scan")).not.toBeInTheDocument(); }); + + it("disables Scan while an ad-hoc scan is in flight", async () => { + mockedUseOverview.mockReturnValue(overviewWith([scan("running", "scan-adhoc")])); + renderPage(); + const button = await screen.findByTestId("project-detail-scan"); + expect(button).toBeDisabled(); + expect(button).toHaveAttribute("data-scan-blocked", "active"); + }); + + it("leaves Scan usable while a branch scan runs — it cannot conflict", async () => { + // The gate is per-(project, branch) and this button triggers an ad-hoc + // run, so a CI scan on main must not grey it out. + mockedUseOverview.mockReturnValue( + overviewWith([scan("running", "scan-main", "main")]), + ); + renderPage(); + const button = await screen.findByTestId("project-detail-scan"); + expect(button).toBeEnabled(); + expect(button).not.toHaveAttribute("data-scan-blocked"); + // The chip still reports the branch scan — it is informational, not a lock. + expect(screen.getByTestId("project-detail-active-scan")).toBeInTheDocument(); + }); }); diff --git a/apps/frontend/tests/unit/features/projects/OverviewTab.test.tsx b/apps/frontend/tests/unit/features/projects/OverviewTab.test.tsx index 74a4c20..fda8cc0 100644 --- a/apps/frontend/tests/unit/features/projects/OverviewTab.test.tsx +++ b/apps/frontend/tests/unit/features/projects/OverviewTab.test.tsx @@ -338,6 +338,7 @@ describe("OverviewTab", () => { completed_at: "2026-05-01T12:01:30Z", created_at: "2026-05-01T12:00:00Z", release: null, + ref: null, }, ], }), @@ -368,6 +369,7 @@ describe("OverviewTab", () => { completed_at: null, created_at: "2026-05-01T12:00:00Z", release: null, + ref: null, }, ], }), @@ -396,6 +398,7 @@ describe("OverviewTab", () => { completed_at: "2026-05-01T12:01:30Z", created_at: "2026-05-01T12:00:00Z", release: null, + ref: null, }, ], }), diff --git a/apps/frontend/tests/unit/features/projects/RecentScansTable.test.tsx b/apps/frontend/tests/unit/features/projects/RecentScansTable.test.tsx index 4be58ea..7090454 100644 --- a/apps/frontend/tests/unit/features/projects/RecentScansTable.test.tsx +++ b/apps/frontend/tests/unit/features/projects/RecentScansTable.test.tsx @@ -17,6 +17,7 @@ function scan(overrides: Partial = {}): ScanSummary { completed_at: "2026-05-01T12:01:30Z", created_at: "2026-05-01T12:00:00Z", release: null, + ref: null, ...overrides, }; } diff --git a/docs-site/docs/user-guide/scans.md b/docs-site/docs/user-guide/scans.md index 606febc..26efcc7 100644 --- a/docs-site/docs/user-guide/scans.md +++ b/docs-site/docs/user-guide/scans.md @@ -41,8 +41,12 @@ The scan dialog has a **Verbose logs (debug)** toggle (off by default). Leave it A right-slide drawer opens on the project list page with a live progress view backed by a WebSocket connection. You can close the tab — the scan continues on the worker. Reopen the project and reconnect at any time. While a scan is `queued` or `running`, the drawer carries a **Cancel scan** action — see [Cancel a scan](#cancel-a-scan). -:::note Only one scan at a time per project -If a project already has a `queued` or `running` scan, the **Scan** button is disabled on the project detail header and its tooltip points you at the in-progress chip in the header (clicking the chip re-opens the existing scan's progress drawer). Triggering a second scan via the API returns `409 Conflict` with the RFC 7807 extension `scan_already_in_progress: true` — wait for the active scan to reach a terminal state, or **Cancel** it, before starting another. The same guard applies to UI, API, and CI clients. +:::note One scan at a time per branch +A project can scan several branches at once — pushing to `main` and `release/1.x` together no longer makes one CI job wait on the other, because the two write to separate snapshots. What is still serialized is the *same* branch: triggering a second scan for a branch that already has one `queued` or `running` returns `409 Conflict` with the RFC 7807 extension `scan_already_in_progress: true`, and the detail names the branch that is busy. + +Scans triggered without a ref — the **Scan** button, and API calls that omit `metadata.ref` — all count as one bucket, so two of those still conflict with each other. That is why the button greys out only while another ad-hoc scan is in flight; a CI scan on `main` leaves it usable, and the in-progress chip beside it stays visible either way (clicking it re-opens that scan's drawer). + +Wait for the conflicting scan to reach a terminal state, or **Cancel** it, before re-triggering. The same guard applies to UI, API, and CI clients. ::: ![Scan progress drawer — bootstrap → fetch → cdxgen → scancode → vuln_match → finalize stages, live over WebSocket](/img/screenshots/user-scans-progress-drawer.png) @@ -381,7 +385,7 @@ The scan reached a terminal state (`succeeded` / `failed` / `cancelled`) between ### A second scan won't start — the **Scan** button is greyed out -The project already has a `queued` or `running` scan. Only one active scan per project is allowed. Open the in-progress chip in the project header (or the row in the global queue) to see the existing run, wait for it to finish, or **Cancel** it before starting another. See [Only one scan at a time per project](#from-the-ui). +The project already has an ad-hoc (`queued` or `running`) scan, and the button triggers another of the same kind. Only one active scan per branch is allowed, and every ref-less trigger shares one bucket. Open the in-progress chip in the project header (or the row in the global queue) to see the existing run, wait for it to finish, or **Cancel** it before starting another. A scan running on a named branch does not disable the button — see [One scan at a time per branch](#from-the-ui). ### A completed scan's drawer shows a spinner that never finishes diff --git a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/scans.md b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/scans.md index d1bc11d..512386f 100644 --- a/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/scans.md +++ b/docs-site/i18n/ko/docusaurus-plugin-content-docs/current/user-guide/scans.md @@ -43,8 +43,12 @@ sidebar_position: 2 ![스캔 진행 드로어 — bootstrap → fetch → cdxgen → scancode → vuln_match → finalize 단계, WebSocket 실시간 표시](/img/screenshots/user-scans-progress-drawer.png) -:::note 프로젝트당 동시 스캔은 하나 -프로젝트에 이미 `queued` 또는 `running` 스캔이 있으면 프로젝트 상세 헤더의 **Scan** 버튼이 비활성화되고, 헤더의 진행중 칩(클릭 시 기존 스캔의 진행 드로어 재오픈)을 가리키는 툴팁이 표시됩니다. API로 두 번째 스캔을 트리거하면 `409 Conflict`와 RFC 7807 확장 필드 `scan_already_in_progress: true`를 반환합니다 — 활성 스캔이 종료 상태에 도달하거나 **Cancel** 한 뒤 다시 시작하세요. 동일한 가드가 UI·API·CI 클라이언트에 적용됩니다. +:::note 브랜치당 동시 스캔은 하나 +한 프로젝트에서 여러 브랜치를 동시에 스캔할 수 있습니다. `main`과 `release/1.x`에 함께 push해도 한쪽 CI가 다른 쪽 스캔을 기다리지 않습니다. 두 브랜치는 서로 다른 스냅샷에 쓰기 때문입니다. 여전히 직렬화되는 것은 같은 브랜치입니다. 이미 `queued` 또는 `running` 스캔이 있는 브랜치에 두 번째 스캔을 트리거하면 `409 Conflict`와 RFC 7807 확장 필드 `scan_already_in_progress: true`를 반환하고, detail에 어느 브랜치가 사용 중인지 밝힙니다. + +ref 없이 트리거한 스캔은 — **Scan** 버튼과 `metadata.ref`를 생략한 API 호출 — 모두 한 묶음으로 셉니다. 그래서 그중 둘은 여전히 서로 충돌합니다. 버튼이 비활성화되는 것도 다른 ref 없는 스캔이 실행 중일 때뿐이고, `main`의 CI 스캔이 도는 중에는 버튼을 쓸 수 있습니다. 진행중 칩은 어느 경우에도 그대로 보이며, 클릭하면 해당 스캔의 드로어가 열립니다. + +충돌한 스캔이 종료 상태에 도달하거나 **Cancel** 한 뒤 다시 트리거하세요. 동일한 가드가 UI·API·CI 클라이언트에 적용됩니다. ::: :::warning 소스 스캔의 브랜치 선택 @@ -379,7 +383,7 @@ docker-compose -f docker-compose.yml exec worker \ ### 두 번째 스캔이 시작되지 않음 — **Scan** 버튼이 비활성 -프로젝트에 이미 `queued` 또는 `running` 스캔이 있습니다. 프로젝트당 활성 스캔은 하나만 허용됩니다. 프로젝트 헤더의 진행중 칩(또는 글로벌 큐의 행)에서 기존 실행을 확인하고 완료를 기다리거나 **Cancel** 한 뒤에 새 스캔을 시작하세요. [프로젝트당 동시 스캔은 하나](#ui에서) 참고. +프로젝트에 이미 ref 없는(`queued` 또는 `running`) 스캔이 있고, 버튼은 같은 종류를 하나 더 트리거합니다. 브랜치당 활성 스캔은 하나만 허용되며 ref 없는 트리거는 전부 한 묶음으로 셉니다. 프로젝트 헤더의 진행중 칩(또는 글로벌 큐의 행)에서 기존 실행을 확인하고 완료를 기다리거나 **Cancel** 한 뒤에 새 스캔을 시작하세요. 이름 있는 브랜치에서 도는 스캔은 버튼을 막지 않습니다. [브랜치당 동시 스캔은 하나](#ui에서) 참고. ### 완료된 스캔 드로어의 스피너가 멈추지 않음