From 130a538462d9b7f96b244756db57a73bee5de968 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:23:43 +0800 Subject: [PATCH 1/3] fix(reset): cancel each live worker once during quiesce cancel_career_task already cancels a career task's live worker; the follow-up _cancel_task_objects cancelled it again. The second cancel could land inside the worker's final awaited DB commit, orphaning an open SQLite write transaction (RESERVED lock) so the reset's checkpoint hit 'database is locked' after the 30s busy timeout. Flaky on main too. Skip tasks that already have a pending cancellation; still await them. (cherry picked from commit b15017f56941cf75c7fcd4ccaea5a557fe4b20d7) --- backend/app/services/local_data_reset.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/backend/app/services/local_data_reset.py b/backend/app/services/local_data_reset.py index c4b4d4ba..f661f490 100644 --- a/backend/app/services/local_data_reset.py +++ b/backend/app/services/local_data_reset.py @@ -377,6 +377,13 @@ async def _cancel_task_objects(tasks: list[asyncio.Task[Any]]) -> int: current = asyncio.current_task() active = [task for task in tasks if task is not current and not task.done()] for task in active: + # A worker may already have been asked to stop (cancel_career_task + # cancels its live task). A second cancel() would land inside the + # worker's final awaited materialization -- e.g. mid-commit -- and + # orphan an open SQLite write transaction that holds the database + # lock for the rest of the reset. Cancel once, then await it. + if task.cancelling(): + continue task.cancel() if active: await asyncio.gather(*active, return_exceptions=True) From acf41d380511faea040298414283edcbc54a93e7 Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:25:19 +0800 Subject: [PATCH 2/3] feat(resume): finalize a proposal when its last decision group is confirmed Confirming every resume decision group left the proposal 'in_review' with no accepted ResumeVersion. A confirmed decision-plan node now finalizes the proposal in the same review transaction once no change is pending: snapshot the workspace as a ResumeVersion and mark it accepted through mark_workspace_proposal_accepted, the helper now shared with create_resume_version_record (all-rejected closes it as 'rejected'). Idempotent, and a replayed final review finalizes a fully reviewed but unfinalized proposal. Direct workspace reviews keep the existing contract: the proposal stays open until the user saves a version. Drop the strict xfail on test_tailor_resume_fixture_runs_ask_plan_receipts_and_workspace_on_one_run. (cherry picked from commit 42203d951c84987cf42d2d4d2ccd61a31e969482) --- backend/app/ops.py | 8 ++- .../app/services/resume_route_operations.py | 18 ++---- backend/app/services/resume_versions.py | 37 +++++++++++ backend/app/services/resume_workspace.py | 62 ++++++++++++++++++- 4 files changed, 109 insertions(+), 16 deletions(-) diff --git a/backend/app/ops.py b/backend/app/ops.py index df1cf5b5..a20192f0 100644 --- a/backend/app/ops.py +++ b/backend/app/ops.py @@ -2076,7 +2076,13 @@ async def _persist_external_resume_proposal(**kwargs: Any) -> dict[str, Any]: async def _review_resume_proposal_items(**kwargs: Any) -> dict[str, Any]: from app.services.resume_workspace import review_resume_proposal_items - return await review_resume_proposal_items(**kwargs) + # A confirmed decision-plan node is the user's adoption decision: once + # its review leaves no pending change, the proposal is finalized there. + # A direct workspace review keeps the proposal open until the user saves + # a version (create_resume_version_record finalizes it then). + authorization = _OPERATION_AUTHORIZATION.get() + finalize = bool(authorization is not None and getattr(authorization, "plan_id", "")) + return await review_resume_proposal_items(**kwargs, finalize_when_complete=finalize) async def _propose_resume_decision_plan(**kwargs: Any) -> dict[str, Any]: diff --git a/backend/app/services/resume_route_operations.py b/backend/app/services/resume_route_operations.py index 0a7f80e9..54f0c173 100644 --- a/backend/app/services/resume_route_operations.py +++ b/backend/app/services/resume_route_operations.py @@ -38,7 +38,11 @@ from app.services.application_workspace import auto_write_job_to_total from app.services.resume_drafts import save_resume_draft from app.services.resume_fact_gates import validate_generated_content -from app.services.resume_versions import create_version_snapshot, snapshot_resume +from app.services.resume_versions import ( + create_version_snapshot, + mark_workspace_proposal_accepted, + snapshot_resume, +) from app.services.security_redaction import safe_error_message from app.runtime_paths import runtime_uploads_dir @@ -796,17 +800,7 @@ async def create_resume_version_record( ).scalars().all() ) for proposal in pending_proposals: - change_ids = { - item.get("change_id") - for item in (proposal.diff_json or []) - if isinstance(item, dict) and item.get("change_id") - } - reviewed_ids = set((proposal.item_reviews_json or {}).keys()) - if change_ids.issubset(reviewed_ids): - proposal.status = "accepted" - proposal.accepted_resume_id = resume.id - proposal.accepted_resume_version_id = version.id - proposal.reviewed_at = datetime.now(timezone.utc).replace(tzinfo=None) + mark_workspace_proposal_accepted(proposal, resume=resume, version=version) from app.services.career_resume import added_resume_evidence added_evidence = added_resume_evidence( diff --git a/backend/app/services/resume_versions.py b/backend/app/services/resume_versions.py index 61dcb614..ae038e7b 100644 --- a/backend/app/services/resume_versions.py +++ b/backend/app/services/resume_versions.py @@ -75,3 +75,40 @@ async def create_version_snapshot( last_exc = exc continue raise RuntimeError("resume_version race: failed after 3 attempts") from last_exc + + +_OPEN_WORKSPACE_PROPOSAL_STATES = ("ready", "in_review") + + +def proposal_fully_reviewed(proposal) -> bool: + """Every diff row of a workspace-bound proposal has a recorded review. + + A proposal without diff rows has nothing left to review. + """ + change_ids = { + str(item.get("change_id")) + for item in (proposal.diff_json or []) + if isinstance(item, dict) and item.get("change_id") + } + reviews = proposal.item_reviews_json or {} + return all( + isinstance(reviews.get(change_id), dict) for change_id in change_ids + ) + + +def mark_workspace_proposal_accepted(proposal, *, resume: Resume, version: ResumeVersion) -> bool: + """Finalize a fully reviewed workspace proposal onto ``version``. + + Shared by the explicit "save version" path and the per-item review path, + so both record the same terminal state. Idempotent: an already-finalized + (or stale/blocked/rejected) proposal is left untouched. + """ + from datetime import datetime, timezone + + if proposal.status not in _OPEN_WORKSPACE_PROPOSAL_STATES or not proposal_fully_reviewed(proposal): + return False + proposal.status = "accepted" + proposal.accepted_resume_id = resume.id + proposal.accepted_resume_version_id = version.id + proposal.reviewed_at = datetime.now(timezone.utc).replace(tzinfo=None) + return True diff --git a/backend/app/services/resume_workspace.py b/backend/app/services/resume_workspace.py index b1a4a9cf..c7c56069 100644 --- a/backend/app/services/resume_workspace.py +++ b/backend/app/services/resume_workspace.py @@ -33,7 +33,11 @@ from app.services.resume_fact_gates import validate_edited_text from app.services.resume_builder import _profile_to_contact_json from app.services.resume_optimization import _proposal_detail -from app.services.resume_versions import create_version_snapshot +from app.services.resume_versions import ( + create_version_snapshot, + mark_workspace_proposal_accepted, + proposal_fully_reviewed, +) from app.services.packet_readiness import project_packet_state @@ -632,11 +636,54 @@ async def review_resume_proposal_item( return await _review_resume_proposal_items(proposal_id, resume_id, [change_id], action, edited_text) +async def _finalize_fully_reviewed_proposal(db, proposal, resume: Resume) -> bool: + """Close a workspace proposal once its last change has been reviewed. + + With at least one accepted change the workspace now holds the adopted + resume: snapshot it as a ResumeVersion and mark the proposal accepted + through the same helper the "save version" path uses. When every change + was rejected nothing was adopted, so the proposal is closed as rejected. + Runs inside the caller's transaction; a no-op once finalized. + """ + if proposal.status not in {"ready", "in_review"} or not proposal_fully_reviewed(proposal): + return False + reviews = proposal.item_reviews_json or {} + change_ids = [ + str(item.get("change_id")) + for item in (proposal.diff_json or []) + if isinstance(item, dict) and item.get("change_id") + ] + if not any(reviews[change_id].get("action") == "accept" for change_id in change_ids): + proposal.status = "rejected" + proposal.reviewed_at = _now() + return True + # Sections added by this review were flushed by resume_id only; reload the + # collection so the version snapshot holds the adopted content. + await db.flush() + await db.refresh(resume, attribute_names=["sections"]) + version = await create_version_snapshot( + db, + resume, + change_summary=f"采纳简历优化提案 {proposal.proposal_id} 的全部审核结果", + created_by="resume_workspace_review", + ) + resume.current_version_id = version.id + return mark_workspace_proposal_accepted(proposal, resume=resume, version=version) + + async def review_resume_proposal_items( proposal_id: str, resume_id: int, change_ids: list[str], action: str, + *, finalize_when_complete: bool = False, ) -> dict[str, Any]: - """Apply one explicitly reviewed set atomically; never approve unrelated operations.""" - return await _review_resume_proposal_items(proposal_id, resume_id, change_ids, action) + """Apply one explicitly reviewed set atomically; never approve unrelated operations. + + ``finalize_when_complete`` (set for confirmed decision-plan nodes) closes + the proposal in the same transaction once no change is left pending. + """ + return await _review_resume_proposal_items( + proposal_id, resume_id, change_ids, action, + finalize_when_complete=finalize_when_complete, + ) async def _review_resume_proposal_items( @@ -645,6 +692,8 @@ async def _review_resume_proposal_items( change_ids: list[str], action: str, edited_text: str = "", + *, + finalize_when_complete: bool = False, ) -> dict[str, Any]: clean_proposal_id = _text(proposal_id, "proposal_id", 80) clean_resume_id = _positive_id(resume_id, "resume_id") @@ -683,6 +732,11 @@ async def _review_resume_proposal_items( raise ValueError("该条目已有不同审核结果,请重新查看提案") pending_ids = [change_id for change_id in clean_change_ids if not isinstance(reviews.get(change_id), dict)] if not pending_ids: + # A replayed last review still finalizes a proposal whose earlier + # attempt recorded every review but was never finalized. + if finalize_when_complete and await _finalize_fully_reviewed_proposal(db, proposal, resume): + await db.commit() + resume = await _load_resume(db, resume.id) return { **await _workspace_payload(db, resume, job=job, proposal_id=proposal.proposal_id), "duplicate": True, @@ -843,6 +897,8 @@ async def _review_resume_proposal_items( proposal.status = "in_review" if clean_action == "accept": proposal.workspace_snapshot_hash = workspace_content_hash(resume) + if finalize_when_complete: + await _finalize_fully_reviewed_proposal(db, proposal, resume) await db.commit() resume = await _load_resume(db, resume.id) return { From 246edd2736b2c5d829c0e56e55c68259132a6fea Mon Sep 17 00:00:00 2001 From: avabbbb <89175608+avabbbb@users.noreply.github.com> Date: Fri, 9 Oct 2026 09:25:41 +0800 Subject: [PATCH 3/3] test(career): pin automation-event lookups to the test's own target (shared DB order leak) (cherry picked from commit 5da56feb4e06495782e8662759ea0e11c40d1e0e) --- backend/tests/test_career_questions.py | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/backend/tests/test_career_questions.py b/backend/tests/test_career_questions.py index af6b1b89..2fb21be6 100644 --- a/backend/tests/test_career_questions.py +++ b/backend/tests/test_career_questions.py @@ -280,7 +280,9 @@ async def inspect() -> tuple[ events = ( await db.execute( select(AutomationEvent).where( - AutomationEvent.dedupe_key.like("career-answer-reprepare:%") + AutomationEvent.dedupe_key.like("career-answer-reprepare:%"), + # The suite shares one database; pin to this test's job. + AutomationEvent.target_id == str(outcome["job_id"]), ) ) ).scalars().all() @@ -421,7 +423,9 @@ async def inspect() -> AutomationEvent | None: rows = ( await db.execute( select(AutomationEvent).where( - AutomationEvent.dedupe_key.like("career-answer-rediscovery:%") + AutomationEvent.dedupe_key.like("career-answer-rediscovery:%"), + # The suite shares one database; pin to this test's profile. + AutomationEvent.target_id == str(outcome["profile_id"]), ) ) ).scalars().all()