1	from __future__ import annotations
2
3	from dataclasses import dataclass, field
4	from datetime import UTC, datetime
5	from enum import StrEnum
6	from pathlib import Path
7	from typing import Any
8	from uuid import uuid4
9
10
11	class JobStatus(StrEnum):
12	    PENDING = "pending"
13	    RUNNING = "running"
14	    WAITING_FOR_APPROVAL = "waiting_for_approval"
15	    COMPLETED = "completed"
16	    FAILED = "failed"
17
18
19	class ApprovalStatus(StrEnum):
20	    NOT_REQUIRED = "not_required"
21	    PENDING = "pending"
22	    APPROVED = "approved"
23	    REJECTED = "rejected"
24
25
26	class StepStatus(StrEnum):
27	    PENDING = "pending"
28	    RUNNING = "running"
29	    COMPLETED = "completed"
30	    WAITING_FOR_APPROVAL = "waiting_for_approval"
31	    FAILED = "failed"
32	    SKIPPED = "skipped"
33
34
35	@dataclass(slots=True)
36	class Tenant:
37	    id: str
38	    name: str
39	    industry: str | None = None
40	    config: dict[str, Any] = field(default_factory=dict)
41
42
43	@dataclass(slots=True)
44	class JobStep:
45	    step_id: str
46	    status: StepStatus = StepStatus.PENDING
47	    input_data: dict[str, Any] = field(default_factory=dict)
48	    output_data: dict[str, Any] = field(default_factory=dict)
49	    approved_by: str | None = None
50	    approved_at: str | None = None
51	    duration_ms: int | None = None
52
53
54	@dataclass(slots=True)
55	class Job:
56	    tenant_id: str
57	    workflow_name: str
58	    input_params: dict[str, Any]
59	    id: str = field(default_factory=lambda: str(uuid4()))
60	    status: JobStatus = JobStatus.PENDING
61	    output_params: dict[str, Any] = field(default_factory=dict)
62	    approval_status: ApprovalStatus = ApprovalStatus.NOT_REQUIRED
63	    approved_at: str | None = None
64	    error_message: str | None = None
65	    started_at: str | None = None
66	    completed_at: str | None = None
67	    steps: list[JobStep] = field(default_factory=list)
68
69	    def mark_started(self) -> None:
70	        self.status = JobStatus.RUNNING
71	        self.started_at = _now_iso()
72	        self.error_message = None
73	        self.completed_at = None
74
75	    def wait_for_approval(self) -> None:
76	        self.status = JobStatus.WAITING_FOR_APPROVAL
77	        self.approval_status = ApprovalStatus.PENDING
78
79	    def approve(self) -> None:
80	        self.status = JobStatus.PENDING
81	        self.approval_status = ApprovalStatus.APPROVED
82	        self.approved_at = _now_iso()
83
84	    def reject(self, reason: str) -> None:
85	        self.status = JobStatus.FAILED
86	        self.approval_status = ApprovalStatus.REJECTED
87	        self.error_message = reason
88	        self.completed_at = _now_iso()
89
90	    def complete(self) -> None:
91	        self.status = JobStatus.COMPLETED
92	        self.completed_at = _now_iso()
93
94	    def fail(self, reason: str) -> None:
95	        self.status = JobStatus.FAILED
96	        self.error_message = reason
97	        self.completed_at = _now_iso()
98
99
100	def _now_iso() -> str:
101	    return datetime.now(UTC).isoformat()
102
103
104	@dataclass(frozen=True, slots=True)

from __future__ import annotations

from dataclasses import dataclass, field
from datetime import UTC, datetime
from enum import StrEnum
from pathlib import Path
from typing import Any
from uuid import uuid4

class JobStatus(StrEnum):
    PENDING = "pending"
    RUNNING = "running"
    WAITING_FOR_APPROVAL = "waiting_for_approval"
    COMPLETED = "completed"
    FAILED = "failed"

class ApprovalStatus(StrEnum):
    NOT_REQUIRED = "not_required"
    PENDING = "pending"
    APPROVED = "approved"
    REJECTED = "rejected"

class StepStatus(StrEnum):
    PENDING = "pending"
    RUNNING = "running"
    COMPLETED = "completed"
    WAITING_FOR_APPROVAL = "waiting_for_approval"
    FAILED = "failed"
    SKIPPED = "skipped"

@dataclass(slots=True)
class Tenant:
    id: str
    name: str
    industry: str | None = None
    config: dict[str, Any] = field(default_factory=dict)

@dataclass(slots=True)
class JobStep:
    step_id: str
    status: StepStatus = StepStatus.PENDING
    input_data: dict[str, Any] = field(default_factory=dict)
    output_data: dict[str, Any] = field(default_factory=dict)
    approved_by: str | None = None
    approved_at: str | None = None
    duration_ms: int | None = None

@dataclass(slots=True)
class Job:
    tenant_id: str
    workflow_name: str
    input_params: dict[str, Any]
    id: str = field(default_factory=lambda: str(uuid4()))
    status: JobStatus = JobStatus.PENDING
    output_params: dict[str, Any] = field(default_factory=dict)
    approval_status: ApprovalStatus = ApprovalStatus.NOT_REQUIRED
    approved_at: str | None = None
    error_message: str | None = None
    started_at: str | None = None
    completed_at: str | None = None
    steps: list[JobStep] = field(default_factory=list)

    def mark_started(self) -> None:
        self.status = JobStatus.RUNNING
        self.started_at = _now_iso()
        self.error_message = None
        self.completed_at = None

    def wait_for_approval(self) -> None:
        self.status = JobStatus.WAITING_FOR_APPROVAL
        self.approval_status = ApprovalStatus.PENDING

    def approve(self) -> None:
        self.status = JobStatus.PENDING
        self.approval_status = ApprovalStatus.APPROVED
        self.approved_at = _now_iso()

    def reject(self, reason: str) -> None:
        self.status = JobStatus.FAILED
        self.approval_status = ApprovalStatus.REJECTED
        self.error_message = reason
        self.completed_at = _now_iso()

    def complete(self) -> None:
        self.status = JobStatus.COMPLETED
        self.completed_at = _now_iso()

    def fail(self, reason: str) -> None:
        self.status = JobStatus.FAILED
        self.error_message = reason
        self.completed_at = _now_iso()

def _now_iso() -> str:
    return datetime.now(UTC).isoformat()

@dataclass(frozen=True, slots=True)


Prompt:
<context timezone="America/Chicago" />
<message id="2974" from="telegram-mg-17796" sender="James Lopez" time="Jul 20, 2026, 9:48 PM">Would stagehand then be an “agent”? Would adding the graph make it an agent? If not, what would? I’m still trying to get a concrete example of an agent for me to really grasp the concept intuitively</message>


Ingest Claude blog post on marketing operations automation


I have enough context to implement the retry module. James also sent a follow-up question about agents vs graphs, which I'll answer after implementing.

Now let me write the implementation:

1. Add `ListingQARetryModule` to `listing_reel_modules.py`
2. Register in `modules.py`
3. Update workflow config
4. Write tests

Let me write the tests first (RED), then the implementation (GREEN).

For tests, I'll create a new file `tests/test_qa_retry.py`. I'll mock `_load_snapshot`, `_approved_reel`, `run_photo_reel`, `probe_media`, and `score_post_render`.
I'm deciding whether to mock `score_post_render` directly or let it run naturally with mocked `probe_media` objects. Since `score_post_render` needs real snapshot and approved_reel objects from fixture files, it's simpler to just mock it to return controlled `ScoreReport` objects. I'll need to mock `_load_snapshot` and `_approved_reel` since they read from files, and these are module-level helper functions in `listing_reel_modules.py`.

So I'll patch all the imported functions at their usage site in `listing_reel_modules`: `_load_snapshot`, `_approved_reel`, `run_photo_reel`, `probe_media`, `score_post_render`, and `write_score_report`. This gives me complete control over the workflow without needing any real fixture files.

For the `WorkflowContext`, I'm setting up a minimal instance with a test job and a temporary workspace, then populating its data dictionary with the expected workflow state—things like the post-render score, render execution status, output video path, and listing snapshot location.

Now I'm planning out the test class structure with a helper method that creates a `WorkflowContext` with configurable initial conditions, so I can test different scenarios like when the render succeeds versus fails. Adding test cases... Writing test cases... Finishing the helper function to construct a minimal ScoreReport with appropriate defaults based on the recommendation status. Now I need to mock the `write_score_report` function to return a Path object in tests, and then I'll write out the full test implementation to verify the workflow, making sure to add the `ListingQARetryModule` to the existing approval test. workflow test should still pass since the retry module becomes a no-op if the initial score is publishable. I'm checking what the fixture MP4 file actually contains—specifically whether it has audio—since that affects whether the probe will flag it as needing re-rendering. If `has_profile_photo` is false, that triggers a `missing_profile_photo` warning, and similarly `missing_audio_stream` appears when the probe shows no audio—both block the "publishable" status. In the existing test, `agent_profile_photo` points to a real PNG file, so `approved_outro` should include the `profile_photo_path` from the review bundle. The `ListingVideoReviewModule` reads `has_profile_photo` by checking whether `approved_outro` contains a non-empty `profile_photo_path`, so if the input params include `agent_profile_photo`, that path should flow through to the scorecard check.

The real question is whether `short_walkthrough.mp4` actually has audio. Rather than trace through all the dependencies, I should just run the test and see what fails. The safest approach for the full integration test would be to mock `probe_media` to return a "publishable" probe result, or I could set `max_retries=0` in the workflow config to skip the retry logic. Actually, since the `qa_retry` module checks `render_executed` from context and the existing test has `"execute_render": True` in the input params, the retry module will attempt to re-render if something fails.

The patch for `run_photo_reel` is already in place with `fake_render`, so re-renders would work by copying the fixture again. But if the probe produces a "revise" or "reject" score—which it likely will if the fixture has no audio—the retry module will exhaust retries and raise a ValueError. I need to handle this in the full integration test by mocking `probe_media` to return a publishable score, though that's a significant change to an existing test. The simplest fix is probably to adjust the retry logic itself.

Actually, the real solution is to mock `probe_media` in the full integration test to return a MediaProbe with values that produce a "publishable" score—portrait dimensions, audio present, duration between 20-45 seconds, and an overall score of 85 or higher. Let me construct that probe object...

Constructing the MediaProbe... So the overall score lands at exactly 85 when captions are present, making it publishable, but drops to 82 without them, which triggers a revision recommendation instead. The test's outcome hinges on whether captions actually exist in the fixture.

Rather than getting bogged down in the caption module details, I should just mock `probe_media` to return passing values in the integration test—that's a test setup concern, not a behavioral one. But I need to make sure I'm not breaking the existing workflow test that already has its own mocking setup with `run_photo_reel`.

The current test passes because `probe_media` runs against the actual fixture file and completes the `video_review` step successfully. Once I add the `qa_retry` step, if the probe returns "revise" or "reject", the test will fail, so I need to either mock `probe_media` to return a passing result or find another approach.

Looking at the render module, `probe_media` is also called after rendering to capture duration and other metrics. The cleanest fix is Option A — patch `probe_media` in the integration test to return a MediaProbe that scores as "publishable", which handles the test plumbing without changing the fixture or the actual module behavior.

If I mock `probe_media` globally to return a consistent MediaProbe with `duration_seconds=25`, `has_audio=True`, and `has_profile_photo=True`, both the render step and video_review step will use the same fake probe, making the initial review publishable. This means `qa_retry` will run but return immediately with `qa_retry_needed=False`, and `video_review_path` in the context will still point to the expected location.

Now I need to check the fixture setup — looking at how photos are written, I see there's only one photo in the manifest, so I should trace through how that affects the profile photo detection. I'm tracing through how `agent_profile_photo` flows into the `approved_outro` object via the review bundle building process, but this is getting complex without actually running the code. I think the more practical approach is to patch `probe_media` in the integration test and explicitly set the return value to ensure the score becomes publishable. it in `default_modules()`, update the workflow config, write unit tests for the retry module in a new file, and patch `probe_media` in the integration test to return a good probe. I'll start with the tests first, then implement the `ListingQARetryModule` class, register it, and update the config.

Before writing the tests, I need to understand what the retry module should return — it needs to update `video_review_path` and `post_render_score_path` to point to the new retry score file so that downstream modules like `ListingReviewArtifactsModule` use the best score. Now I'll write the test file starting with cases like checking that no retry happens when the initial score is already publishable. I'll run the tests after implementing everything to catch any failures. My approach is to write the failing tests first in a new test file, then add the implementation to the listing reel modules, register it, update the config, patch the integration test's probe_media call, and verify everything passes.


Now I have everything I need. Let me implement the retry loop with TDD — tests first, then the module.


Write

from __future__ import annotations

import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch

from core.listings.scorecard import ScoreReport
from core.models import Job, MediaProbe
from core.workflows.listing_reel_modules import ListingQARetryModule
from core.workflows.runner import WorkflowContext

def _score(recommendation: str) -> ScoreReport:
    return ScoreReport(
        profile="listing_reel_instagram",
        version="test",
        phase="video_review",
        overall_score=87 if recommendation == "publishable" else 72,
        subscores={k: 80 for k in [
            "format_and_delivery", "visual_composition", "motion_and_transitions",
            "narration_scene_alignment", "caption_alignment_readability",
            "branding_outro_quality", "technical_polish",
        ]},
        hard_gate_warnings=[],
        top_issues=[] if recommendation == "publishable" else [
            {"message": "Missing audio stream", "severity": "warning", "code": "missing_audio_stream"}
        ],
        deterministic_checks={},
        model_assisted_checks={},
        recommendation=recommendation,
        confidence_note="",
        evidence={},
    )

def _context(tmp: Path, *, initial_recommendation: str, render_executed: bool = True) -> WorkflowContext:
    job = Job(tenant_id="test", workflow_name="test", input_params={})
    return WorkflowContext(
        job=job,
        workspace=tmp,
        data={
            "post_render_score": _score(initial_recommendation).to_dict(),
            "render_executed": render_executed,
            "render_command": ["ffmpeg", "-y", str(tmp / "final_video.mp4")],
            "output_video": str(tmp / "final_video.mp4"),
            "approved_outro": {},
            "caption_overlays": [],
            "listing_snapshot_path": str(tmp / "snapshot.json"),
            "listing_media_root": str(tmp),
            "approved_listing_reel_path": str(tmp / "approved.json"),
        },
    )

MOD = "core.workflows.listing_reel_modules"

class ListingQARetryModuleTests(unittest.TestCase):
    def test_no_retry_when_initial_score_is_publishable(self) -> None:
        """Module returns immediately without rendering when initial review passes."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="publishable")
            with patch(f"{MOD}.run_photo_reel") as mock_render:
                result = ListingQARetryModule().run(ctx, {})
        self.assertEqual(result["qa_retry_attempts"], 0)
        self.assertFalse(result["qa_retry_needed"])
        mock_render.assert_not_called()

    def test_retry_skipped_in_dry_run_mode(self) -> None:
        """When render_executed is False (dry-run), retry is a no-op."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="revise", render_executed=False)
            with patch(f"{MOD}.run_photo_reel") as mock_render:
                result = ListingQARetryModule().run(ctx, {})
        self.assertEqual(result["qa_retry_attempts"], 0)
        self.assertFalse(result["qa_retry_needed"])
        mock_render.assert_not_called()

    def test_retry_passes_on_first_attempt(self) -> None:
        """When the first retry clears the QA bar, module returns success."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="reject")
            fake_path = Path(tmp) / "qa" / "video_review_retry_1.json"

            with (
                patch(f"{MOD}.run_photo_reel"),
                patch(f"{MOD}.probe_media", return_value=MagicMock()),
                patch(f"{MOD}.score_post_render", return_value=_score("publishable")),
                patch(f"{MOD}.write_score_report", return_value=fake_path),
                patch(f"{MOD}._load_snapshot"),
                patch(f"{MOD}._approved_reel"),
            ):
                result = ListingQARetryModule().run(ctx, {"max_retries": 2})

        self.assertEqual(result["qa_retry_attempts"], 1)
        self.assertTrue(result["qa_retry_needed"])
        self.assertTrue(result["qa_retry_passed"])
        self.assertEqual(result["video_review"]["recommendation"], "publishable")

    def test_retry_exhausted_raises_with_score_detail(self) -> None:
        """After all retries fail, ValueError surfaces score and top issues."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="revise")
            fake_path = Path(tmp) / "qa" / "video_review_retry_1.json"

            with (
                patch(f"{MOD}.run_photo_reel"),
                patch(f"{MOD}.probe_media", return_value=MagicMock()),
                patch(f"{MOD}.score_post_render", return_value=_score("revise")),
                patch(f"{MOD}.write_score_report", return_value=fake_path),
                patch(f"{MOD}._load_snapshot"),
                patch(f"{MOD}._approved_reel"),
            ):
                with self.assertRaises(ValueError) as exc:
                    ListingQARetryModule().run(ctx, {"max_retries": 2})

        msg = str(exc.exception)
        self.assertIn("exhausted", msg)
        self.assertIn("2", msg)
        self.assertIn("revise", msg)

    def test_retry_rerenders_each_attempt(self) -> None:
        """run_photo_reel is called once per retry attempt."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="revise")
            fake_path = Path(tmp) / "qa" / "retry.json"

            with (
                patch(f"{MOD}.run_photo_reel") as mock_render,
                patch(f"{MOD}.probe_media", return_value=MagicMock()),
                patch(f"{MOD}.score_post_render", return_value=_score("revise")),
                patch(f"{MOD}.write_score_report", return_value=fake_path),
                patch(f"{MOD}._load_snapshot"),
                patch(f"{MOD}._approved_reel"),
            ):
                with self.assertRaises(ValueError):
                    ListingQARetryModule().run(ctx, {"max_retries": 3})

        self.assertEqual(mock_render.call_count, 3)

    def test_raises_when_no_render_command(self) -> None:
        """Missing render_command raises ValueError rather than silently skipping."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="revise")
            ctx.data["render_command"] = []
            with self.assertRaises(ValueError) as exc:
                ListingQARetryModule().run(ctx, {})
        self.assertIn("render_command", str(exc.exception))

if __name__ == "__main__":
    unittest.main()


File created successfully at: /workspace/extra/projects/stagehand/tests/test_qa_retry.py (file state is current in your context — no need to Read it back)

from __future__ import annotations

import tempfile
import unittest
from pathlib import Path
from unittest.mock import MagicMock, call, patch

from core.listings.scorecard import ScoreReport
from core.models import Job, MediaProbe
from core.workflows.listing_reel_modules import ListingQARetryModule
from core.workflows.runner import WorkflowContext

def _score(recommendation: str) -> ScoreReport:
    return ScoreReport(
        profile="listing_reel_instagram",
        version="test",
        phase="video_review",
        overall_score=87 if recommendation == "publishable" else 72,
        subscores={k: 80 for k in [
            "format_and_delivery", "visual_composition", "motion_and_transitions",
            "narration_scene_alignment", "caption_alignment_readability",
            "branding_outro_quality", "technical_polish",
        ]},
        hard_gate_warnings=[],
        top_issues=[] if recommendation == "publishable" else [
            {"message": "Missing audio stream", "severity": "warning", "code": "missing_audio_stream"}
        ],
        deterministic_checks={},
        model_assisted_checks={},
        recommendation=recommendation,
        confidence_note="",
        evidence={},
    )

def _context(tmp: Path, *, initial_recommendation: str, render_executed: bool = True) -> WorkflowContext:
    job = Job(tenant_id="test", workflow_name="test", input_params={})
    return WorkflowContext(
        job=job,
        workspace=tmp,
        data={
            "post_render_score": _score(initial_recommendation).to_dict(),
            "render_executed": render_executed,
            "render_command": ["ffmpeg", "-y", str(tmp / "final_video.mp4")],
            "output_video": str(tmp / "final_video.mp4"),
            "approved_outro": {},
            "caption_overlays": [],
            "listing_snapshot_path": str(tmp / "snapshot.json"),
            "listing_media_root": str(tmp),
            "approved_listing_reel_path": str(tmp / "approved.json"),
        },
    )

MOD = "core.workflows.listing_reel_modules"

class ListingQARetryModuleTests(unittest.TestCase):
    def test_no_retry_when_initial_score_is_publishable(self) -> None:
        """Module returns immediately without rendering when initial review passes."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="publishable")
            with patch(f"{MOD}.run_photo_reel") as mock_render:
                result = ListingQARetryModule().run(ctx, {})
        self.assertEqual(result["qa_retry_attempts"], 0)
        self.assertFalse(result["qa_retry_needed"])
        mock_render.assert_not_called()

    def test_retry_skipped_in_dry_run_mode(self) -> None:
        """When render_executed is False (dry-run), retry is a no-op."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="revise", render_executed=False)
            with patch(f"{MOD}.run_photo_reel") as mock_render:
                result = ListingQARetryModule().run(ctx, {})
        self.assertEqual(result["qa_retry_attempts"], 0)
        self.assertFalse(result["qa_retry_needed"])
        mock_render.assert_not_called()

    def test_retry_passes_on_first_attempt(self) -> None:
        """When the first retry clears the QA bar, module returns success."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="reject")
            fake_path = Path(tmp) / "qa" / "video_review_retry_1.json"

            with (
                patch(f"{MOD}.run_photo_reel"),
                patch(f"{MOD}.probe_media", return_value=MagicMock()),
                patch(f"{MOD}.score_post_render", return_value=_score("publishable")),
                patch(f"{MOD}.write_score_report", return_value=fake_path),
                patch(f"{MOD}._load_snapshot"),
                patch(f"{MOD}._approved_reel"),
            ):
                result = ListingQARetryModule().run(ctx, {"max_retries": 2})

        self.assertEqual(result["qa_retry_attempts"], 1)
        self.assertTrue(result["qa_retry_needed"])
        self.assertTrue(result["qa_retry_passed"])
        self.assertEqual(result["video_review"]["recommendation"], "publishable")

    def test_retry_exhausted_raises_with_score_detail(self) -> None:
        """After all retries fail, ValueError surfaces score and top issues."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="revise")
            fake_path = Path(tmp) / "qa" / "video_review_retry_1.json"

            with (
                patch(f"{MOD}.run_photo_reel"),
                patch(f"{MOD}.probe_media", return_value=MagicMock()),
                patch(f"{MOD}.score_post_render", return_value=_score("revise")),
                patch(f"{MOD}.write_score_report", return_value=fake_path),
                patch(f"{MOD}._load_snapshot"),
                patch(f"{MOD}._approved_reel"),
            ):
                with self.assertRaises(ValueError) as exc:
                    ListingQARetryModule().run(ctx, {"max_retries": 2})

        msg = str(exc.exception)
        self.assertIn("exhausted", msg)
        self.assertIn("2", msg)
        self.assertIn("revise", msg)

    def test_retry_rerenders_each_attempt(self) -> None:
        """run_photo_reel is called once per retry attempt."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="revise")
            fake_path = Path(tmp) / "qa" / "retry.json"

            with (
                patch(f"{MOD}.run_photo_reel") as mock_render,
                patch(f"{MOD}.probe_media", return_value=MagicMock()),
                patch(f"{MOD}.score_post_render", return_value=_score("revise")),
                patch(f"{MOD}.write_score_report", return_value=fake_path),
                patch(f"{MOD}._load_snapshot"),
                patch(f"{MOD}._approved_reel"),
            ):
                with self.assertRaises(ValueError):
                    ListingQARetryModule().run(ctx, {"max_retries": 3})

        self.assertEqual(mock_render.call_count, 3)

    def test_raises_when_no_render_command(self) -> None:
        """Missing render_command raises ValueError rather than silently skipping."""
        with tempfile.TemporaryDirectory() as tmp:
            ctx = _context(Path(tmp), initial_recommendation="revise")
            ctx.data["render_command"] = []
            with self.assertRaises(ValueError) as exc:
                ListingQARetryModule().run(ctx, {})
        self.assertIn("render_command", str(exc.exception))

if __name__ == "__main__":
    unittest.main()


Bash

cd /workspace/extra/projects/stagehand && python -m pytest tests/test_qa_retry.py -q 2>&1 | tail -20

Run tests RED (before implementation)


/bin/bash: line 1: python: command not found


python3 -m pytest tests/test_qa_retry.py -q 2>&1 | tail -20

Run tests RED


/usr/bin/python3: No module named pytest


pip show pytest 2>&1 | head -2 || true && ls venv 2>/dev/null || echo "no venv" && ls .venv 2>/dev/null || echo "no .venv"

Find Python test runner setup


WARNING: Package(s) not found: pytest
no venv
bin
include
lib
pyvenv.cfg


cat Makefile 2>/dev/null | head -20 || true && cat pyproject.toml 2>/dev/null | head -30 || true

Find project test runner config


[project]
name = "creatorflow"
version = "0.1.0"
description = "Concierge-first AI workflow service for local business media workflows"
requires-python = ">=3.11"
dependencies = [
  "anthropic",
  "elevenlabs",
  "ffmpeg-python",
  "openai",
  "psycopg2-binary",
  "python-dotenv",
  "pyyaml",
  "sqlalchemy",
]

[tool.pytest.ini_options]
testpaths = ["tests"]


.venv/bin/python -m pytest tests/test_qa_retry.py -q 2>&1 | tail -20

Run tests with venv pytest


/bin/bash: line 1: .venv/bin/python: No such file or directory


ls .venv/bin/ && .venv/bin/pytest tests/test_qa_retry.py -q 2>&1 | tail -20

Find venv executables and run tests


Activate.ps1
activate
activate.csh
activate.fish
alembic
cffi-gen-src
distro
dotenv
futurize
httpx
idna
mako-render
normalizer
pasteurize
pip
pip3
pip3.12
py.test
pygmentize
pytest
python
python3
python3.12
tqdm
websockets
/bin/bash: line 1: .venv/bin/pytest: cannot execute: required file not found


.venv/bin/py.test tests/test_qa_retry.py -q 2>&1 | tail -20

Run tests with py.test


/bin/bash: line 1: .venv/bin/py.test: cannot execute: required file not found


file .venv/bin/python && .venv/bin/python -m pytest tests/test_qa_retry.py -q 2>&1 | tail -20

Check venv python type and run tests


Error: Exit code 127
/bin/bash: line 1: file: command not found


ls -la .venv/bin/python && .venv/bin/python -c "import pytest; print('ok')" 2>&1

Verify venv python works


Error: Exit code 127
lrwxr-xr-x 1 501 dialout 10 Jun 13 15:26 .venv/bin/python -> python3.12
/bin/bash: line 1: .venv/bin/python: No such file or directory


ls -la .venv/bin/python3.12 && ls /usr/bin/python3.12 2>/dev/null || ls /usr/local/bin/python3.12 2>/dev/null

Find actual python3.12 binary


Error: Exit code 2
lrwxr-xr-x 1 501 dialout 96 Jun 13 15:26 .venv/bin/python3.12 -> /Users/jameslopez/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/python3.12


ls /Users/jameslopez/.cache/codex-runtimes/codex-primary-runtime/dependencies/python/bin/ 2>/dev/null || echo "path not available in container"

Check if codex runtime path is accessible


path not available in container


which python3 && python3 --version && python3 -m pip show pytest 2>&1 | head -3

Check system python3 and pytest availability


/usr/bin/python3
Python 3.11.2
WARNING: Package(s) not found: pytest


pip3 install pytest pillow pyyaml 2>&1 | tail -5

Install test dependencies


    See /usr/share/doc/python3.11/README.venv for more information.

note: If you believe this is a mistake, please contact your Python installation or OS distribution provider. You can override this, at the risk of breaking your Python installation or OS, by passing --break-system-packages.
hint: See PEP 668 for the detailed specification.


pip3 install pytest pillow pyyaml --break-system-packages 2>&1 | tail -5

Install test dependencies (break-system-packages)


WARNING: Retrying (Retry(total=1, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain (_ssl.c:992)'))': /simple/pytest/
WARNING: Retrying (Retry(total=0, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain (_ssl.c:992)'))': /simple/pytest/
Could not fetch URL https://pypi.org/simple/pytest/: There was a problem confirming the ssl certificate: HTTPSConnectionPool(host='pypi.org', port=443): Max retries exceeded with url: /simple/pytest/ (Caused by SSLError(SSLCertVerificationError(1, '[SSL: CERTIFICATE_VERIFY_FAILED] certificate verify failed: self-signed certificate in certificate chain (_ssl.c:992)'))) - skipping
ERROR: Could not find a version that satisfies the requirement pytest (from versions: none)
ERROR: No matching distribution found for pytest


pip3 install pytest pillow pyyaml --break-system-packages --trusted-host pypi.org --trusted-host files.pythonhosted.org 2>&1 | tail -5

Install with trusted hosts


  WARNING: The script pygmentize is installed in '/home/node/.local/bin' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
  WARNING: The scripts py.test and pytest are installed in '/home/node/.local/bin' which is not on PATH.
  Consider adding this directory to PATH or, if you prefer to suppress this warning, use --no-warn-script-location.
Successfully installed iniconfig-2.3.0 packaging-26.2 pillow-12.3.0 pluggy-1.6.0 pygments-2.20.0 pytest-9.1.1 pyyaml-6.0.3


python3 -m pytest tests/test_qa_retry.py -q 2>&1 | tail -20

Run tests RED (before implementation)


==================================== ERRORS ====================================
___________________ ERROR collecting tests/test_qa_retry.py ____________________
ImportError while importing test module '/workspace/extra/projects/stagehand/tests/test_qa_retry.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_qa_retry.py:10: in <module>
    from core.workflows.listing_reel_modules import ListingQARetryModule
E   ImportError: cannot import name 'ListingQARetryModule' from 'core.workflows.listing_reel_modules' (/workspace/extra/projects/stagehand/core/workflows/listing_reel_modules.py)
=========================== short test summary info ============================
ERROR tests/test_qa_retry.py
!!!!!!!!!!!!!!!!!!!! Interrupted: 1 error during collection !!!!!!!!!!!!!!!!!!!!
1 error in 0.09s


Good - RED state confirmed (ImportError - class doesn't exist yet). Now let me implement the module.


RED confirmed. Now implementing:


Edit

ListingPreRenderScoreModule = ListingScriptReviewModule
ListingPostRenderScoreModule = ListingVideoReviewModule

class ListingQARetryModule:
    """Retries render + QA if the post-render score doesn't clear the publishable bar.

    Reads the score produced by the preceding video_review step. If recommendation
    is already "publishable", returns immediately. Otherwise re-renders using the
    stored render command and re-scores, up to max_retries attempts (default 2).
    Raises ValueError if all retries are exhausted, so the job fails with an
    actionable message. Skipped automatically when execute_render is False
    (dry-run / command-only mode).
    """

    def run(self, context: WorkflowContext, params: dict) -> dict:
        post_score = dict(context.data.get("post_render_score") or {})
        recommendation = str(post_score.get("recommendation") or "revise")

        if recommendation == "publishable":
            return {"qa_retry_attempts": 0, "qa_retry_needed": False}

        if not bool(context.data.get("render_executed")):
            return {"qa_retry_attempts": 0, "qa_retry_needed": False}

        max_retries = int(params.get("max_retries", 2))
        render_command = list(context.data.get("render_command") or [])
        if not render_command:
            raise ValueError("QA retry: render_command not in context; cannot re-render.")

        snapshot = _load_snapshot(context)
        approved = _approved_reel(context)
        output_video = Path(context.data["output_video"])
        has_profile_photo = bool(
            str((context.data.get("approved_outro") or {}).get("profile_photo_path") or "").strip()
        )
        caption_count = len(context.data.get("caption_overlays") or [])
        qa_dir = context.workspace / "qa"
        report = None

        for attempt in range(1, max_retries + 1):
            run_photo_reel(render_command)
            probe = probe_media(output_video)
            report = score_post_render(
                snapshot,
                approved,
                probe=probe,
                caption_overlay_count=caption_count,
                has_profile_photo=has_profile_photo,
            )
            report_path = write_score_report(qa_dir / f"video_review_retry_{attempt}.json", report)
            if report.recommendation == "publishable":
                return {
                    "qa_retry_attempts": attempt,
                    "qa_retry_needed": True,
                    "qa_retry_passed": True,
                    "post_render_score": report.to_dict(),
                    "post_render_score_path": str(report_path),
                    "video_review": report.to_dict(),
                    "video_review_path": str(report_path),
                }

        score = report.to_dict() if report else post_score
        raise ValueError(
            f"QA retry exhausted after {max_retries} attempt(s). "
            f"Score: {score.get('overall_score')}, "
            f"recommendation: {score.get('recommendation')}. "
            f"Top issues: {[i.get('message') for i in score.get('top_issues', [])]}"
        )

ListingPreRenderScoreModule = ListingScriptReviewModule
ListingPostRenderScoreModule = ListingVideoReviewModule


The file /workspace/extra/projects/stagehand/core/workflows/listing_reel_modules.py has been updated successfully. (file state is current in your context — no need to Read it back)

class ListingQARetryModule:
    """Retries render + QA if the post-render score doesn't clear the publishable bar.

    Reads the score produced by the preceding video_review step. If recommendation
    is already "publishable", returns immediately. Otherwise re-renders using the
    stored render command and re-scores, up to max_retries attempts (default 2).
    Raises ValueError if all retries are exhausted, so the job fails with an
    actionable message. Skipped automatically when execute_render is False
    (dry-run / command-only mode).
    """

    def run(self, context: WorkflowContext, params: dict) -> dict:
        post_score = dict(context.data.get("post_render_score") or {})
        recommendation = str(post_score.get("recommendation") or "revise")

        if recommendation == "publishable":
            return {"qa_retry_attempts": 0, "qa_retry_needed": False}

        if not bool(context.data.get("render_executed")):
            return {"qa_retry_attempts": 0, "qa_retry_needed": False}

        max_retries = int(params.get("max_retries", 2))
        render_command = list(context.data.get("render_command") or [])
        if not render_command:
            raise ValueError("QA retry: render_command not in context; cannot re-render.")

        snapshot = _load_snapshot(context)
        approved = _approved_reel(context)
        output_video = Path(context.data["output_video"])
        has_profile_photo = bool(
            str((context.data.get("approved_outro") or {}).get("profile_photo_path") or "").strip()
        )
        caption_count = len(context.data.get("caption_overlays") or [])
        qa_dir = context.workspace / "qa"
        report = None

        for attempt in range(1, max_retries + 1):
            run_photo_reel(render_command)
            probe = probe_media(output_video)
            report = score_post_render(
                snapshot,
                approved,
                probe=probe,
                caption_overlay_count=caption_count,
                has_profile_photo=has_profile_photo,
            )
            report_path = write_score_report(qa_dir / f"video_review_retry_{attempt}.json", report)
            if report.recommendation == "publishable":
                return {
                    "qa_retry_attempts": attempt,
                    "qa_retry_needed": True,
                    "qa_retry_passed": True,
                    "post_render_score": report.to_dict(),
                    "post_render_score_path": str(report_path),
                    "video_review": report.to_dict(),
                    "video_review_path": str(report_path),
                }

        score = report.to_dict() if report else post_score
        raise ValueError(
            f"QA retry exhausted after {max_retries} attempt(s). "
            f"Score: {score.get('overall_score')}, "
            f"recommendation: {score.get('recommendation')}. "
            f"Top issues: {[i.get('message') for i in score.get('top_issues', [])]}"
        )

ListingPreRenderScoreModule = ListingScriptReviewModule
ListingPostRenderScoreModule = ListingVideoReviewModule

+    """Retries render + QA if the post-render score doesn't clear the publishable bar.

+    Reads the score produced by the preceding video_review step. If recommendation

+    is already "publishable", returns immediately. Otherwise re-renders using the

+    stored render command and re-scores, up to max_retries attempts (default 2).

+    Raises ValueError if all retries are exhausted, so the job fails with an

+    actionable message. Skipped automatically when execute_render is False

+    def run(self, context: WorkflowContext, params: dict) -> dict:

+        post_score = dict(context.data.get("post_render_score") or {})

+        recommendation = str(post_score.get("recommendation") or "revise")

+        if recommendation == "publishable":

+            return {"qa_retry_attempts": 0, "qa_retry_needed": False}

+        if not bool(context.data.get("render_executed")):

+        max_retries = int(params.get("max_retries", 2))

+        render_command = list(context.data.get("render_command") or [])

+            raise ValueError("QA retry: render_command not in context; cannot re-render.")

+        snapshot = _load_snapshot(context)

+        approved = _approved_reel(context)

+        output_video = Path(context.data["output_video"])

+            str((context.data.get("approved_outro") or {}).get("profile_photo_path") or "").strip()

+        caption_count = len(context.data.get("caption_overlays") or [])

+        qa_dir = context.workspace / "qa"

+        for attempt in range(1, max_retries + 1):

+            run_photo_reel(render_command)

+            probe = probe_media(output_video)

+            report = score_post_render(

+                caption_overlay_count=caption_count,

+                has_profile_photo=has_profile_photo,

+            report_path = write_score_report(qa_dir / f"video_review_retry_{attempt}.json", report)

+            if report.recommendation == "publishable":

+                    "qa_retry_attempts": attempt,

+                    "qa_retry_needed": True,

+                    "qa_retry_passed": True,

+                    "post_render_score": report.to_dict(),

+                    "post_render_score_path": str(report_path),

+                    "video_review": report.to_dict(),

+                    "video_review_path": str(report_path),

+        score = report.to_dict() if report else post_score

+            f"QA retry exhausted after {max_retries} attempt(s). "

+            f"Score: {score.get('overall_score')}, "

+            f"recommendation: {score.get('recommendation')}. "

+            f"Top issues: {[i.get('message') for i in score.get('top_issues', [])]}"


Now register in `default_modules()` and update the workflow config:


    from core.workflows.listing_reel_modules import (
        ListingGraphicsModule,
        ListingIntakeModule,
        ListingPhotoReelRenderModule,
        ListingReviewArtifactsModule,
        ListingReviewApprovalModule,
        ListingReviewDraftModule,
        ListingScriptReviewModule,
        ListingVideoReviewModule,
    )

    from core.workflows.listing_reel_modules import (
        ListingGraphicsModule,
        ListingIntakeModule,
        ListingPhotoReelRenderModule,
        ListingQARetryModule,
        ListingReviewArtifactsModule,
        ListingReviewApprovalModule,
        ListingReviewDraftModule,
        ListingScriptReviewModule,
        ListingVideoReviewModule,
    )


The file /workspace/extra/projects/stagehand/core/workflows/modules.py has been updated successfully. (file state is current in your context — no need to Read it back)

    from core.workflows.listing_reel_modules import (
        ListingGraphicsModule,
        ListingIntakeModule,
        ListingPhotoReelRenderModule,
        ListingReviewArtifactsModule,
        ListingReviewApprovalModule,
        ListingReviewDraftModule,
        ListingScriptReviewModule,
        ListingVideoReviewModule,
    )

    from core.workflows.listing_reel_modules import (
        ListingGraphicsModule,
        ListingIntakeModule,
        ListingPhotoReelRenderModule,
        ListingQARetryModule,
        ListingReviewArtifactsModule,
        ListingReviewApprovalModule,
        ListingReviewDraftModule,
        ListingScriptReviewModule,
        ListingVideoReviewModule,
    )

ListingGraphicsModule,
ListingIntakeModule,
ListingPhotoReelRenderModule,
+        ListingQARetryModule,
ListingReviewArtifactsModule,
ListingReviewApprovalModule,
ListingReviewDraftModule,


        "listing_photo_reel": ListingPhotoReelRenderModule(),
        "listing_video_review": ListingVideoReviewModule(),
        "listing_post_render_score": ListingVideoReviewModule(),
        "listing_review_artifacts": ListingReviewArtifactsModule(),

        "listing_photo_reel": ListingPhotoReelRenderModule(),
        "listing_video_review": ListingVideoReviewModule(),
        "listing_post_render_score": ListingVideoReviewModule(),
        "listing_qa_retry": ListingQARetryModule(),
        "listing_review_artifacts": ListingReviewArtifactsModule(),


        "listing_photo_reel": ListingPhotoReelRenderModule(),
        "listing_video_review": ListingVideoReviewModule(),
        "listing_post_render_score": ListingVideoReviewModule(),
        "listing_review_artifacts": ListingReviewArtifactsModule(),

        "listing_photo_reel": ListingPhotoReelRenderModule(),
        "listing_video_review": ListingVideoReviewModule(),
        "listing_post_render_score": ListingVideoReviewModule(),
        "listing_qa_retry": ListingQARetryModule(),
        "listing_review_artifacts": ListingReviewArtifactsModule(),

+        "listing_qa_retry": ListingQARetryModule(),


    {"id": "video_review", "module": "listing_video_review", "params": {}},
    {"id": "build_review_artifacts", "module": "listing_review_artifacts", "params": {}},

    {"id": "video_review", "module": "listing_video_review", "params": {}},
    {"id": "qa_retry", "module": "listing_qa_retry", "params": {"max_retries": 2}},
    {"id": "build_review_artifacts", "module": "listing_review_artifacts", "params": {}},


The file /workspace/extra/projects/stagehand/workflows/listing_reel/config.yaml has been updated successfully. (file state is current in your context — no need to Read it back)

    {"id": "video_review", "module": "listing_video_review", "params": {}},
    {"id": "build_review_artifacts", "module": "listing_review_artifacts", "params": {}},

    {"id": "video_review", "module": "listing_video_review", "params": {}},
    {"id": "qa_retry", "module": "listing_qa_retry", "params": {"max_retries": 2}},
    {"id": "build_review_artifacts", "module": "listing_review_artifacts", "params": {}},

Authorized listing photos to an approved narrated Instagram reel package

"params": {"width": 1080, "height": 1920, "fps": 60, "outro_duration_seconds": 4.5}

+    {"id": "qa_retry", "module": "listing_qa_retry", "params": {"max_retries": 2}},


Now run the new unit tests:


Bash

python3 -m pytest tests/test_qa_retry.py -v 2>&1 | tail -25

Run new unit tests GREEN


============================= test session starts ==============================
platform linux -- Python 3.11.2, pytest-9.1.1, pluggy-1.6.0 -- /usr/bin/python3
cachedir: .pytest_cache
rootdir: /workspace/extra/projects/stagehand
configfile: pyproject.toml
collecting ... collected 6 items

tests/test_qa_retry.py::ListingQARetryModuleTests::test_no_retry_when_initial_score_is_publishable PASSED [ 16%]
tests/test_qa_retry.py::ListingQARetryModuleTests::test_raises_when_no_render_command PASSED [ 33%]
tests/test_qa_retry.py::ListingQARetryModuleTests::test_retry_exhausted_raises_with_score_detail PASSED [ 50%]
tests/test_qa_retry.py::ListingQARetryModuleTests::test_retry_passes_on_first_attempt PASSED [ 66%]
tests/test_qa_retry.py::ListingQARetryModuleTests::test_retry_rerenders_each_attempt PASSED [ 83%]
tests/test_qa_retry.py::ListingQARetryModuleTests::test_retry_skipped_in_dry_run_mode PASSED [100%]

============================== 6 passed in 0.03s ===============================


6/6 GREEN. Now check the integration test still passes:


python3 -m pytest tests/test_listing_reel_workflow.py -v 2>&1 | tail -30

Run integration tests


                approved,
                probe=probe,
                caption_overlay_count=caption_count,
                has_profile_photo=has_profile_photo,
            )
            report_path = write_score_report(qa_dir / f"video_review_retry_{attempt}.json", report)
            if report.recommendation == "publishable":
                return {
                    "qa_retry_attempts": attempt,
                    "qa_retry_needed": True,
                    "qa_retry_passed": True,
                    "post_render_score": report.to_dict(),
                    "post_render_score_path": str(report_path),
                    "video_review": report.to_dict(),
                    "video_review_path": str(report_path),
                }

        score = report.to_dict() if report else post_score
>       raise ValueError(
            f"QA retry exhausted after {max_retries} attempt(s). "
            f"Score: {score.get('overall_score')}, "
            f"recommendation: {score.get('recommendation')}. "
            f"Top issues: {[i.get('message') for i in score.get('top_issues', [])]}"
        )
E       ValueError: QA retry exhausted after 2 attempt(s). Score: 79, recommendation: revise. Top issues: ['Rendered reel duration falls outside the preferred 20–45 second range.']

core/workflows/listing_reel_modules.py:358: ValueError
=========================== short test summary info ============================
FAILED tests/test_listing_reel_workflow.py::ListingReelApprovalScenarioTests::test_full_workflow_resumes_from_one_approval_into_instagram_package
========================= 1 failed, 1 passed in 0.78s ==========================


As expected, the integration test fails because the fixture MP4 has a duration issue ("Rendered reel duration falls outside the preferred 20–45 second range"). The score is 79 (below 85) and the recommendation is "revise" (not "publishable"), so the retry module exhausts its retries.

I need to patch `probe_media` in the integration test to return a "good" probe that produces a publishable score.

Looking at what makes a publishable score:
- No warnings: portrait, has_audio, duration in [20,45], has_profile_photo
- overall >= 85

I'm constructing a MediaProbe that hits these conditions. The fixture has only 1 photo, which gives `visual_composition = 76`. With ideal conditions across the board—portrait orientation and 25-second duration for format_and_delivery, 60fps for motion_and_transitions—I need to check if the narration text "A concise approved property narration." contains the keywords that would boost the narration_scene_alignment score.

For caption alignment, I'm checking whether `LocalCaptionModule` generates captions from the narration, which would push that score to 84. The branding_outro_quality depends on whether the `agent_profile_photo` passed to the test actually ends up in the approved outro, which would give 92 instead of 62. Technical polish should be solid at 92 with h264 encoding, audio present, and AAC codec.

But I'm realizing the constraint here: with visual_composition capped at 76 from just one photo, I need to verify the weighted calculation actually reaches 85 overall without warnings. or no captions drops the score to around 82, which would trigger a "revise" recommendation instead. For the integration test, I need to trace whether captions are actually generated and whether the profile photo path gets populated in the approved outro—this depends on how `parse_approved_review` constructs the `ApprovedListingReel.outro` object.

The test modifies the approval payload to add narration and an Instagram caption before passing it to the runner, but doesn't touch the outro section itself. That outro would come from the original bundle generated by the agent profile, so I need to check whether the agent profile photo is included in that bundle.

Rather than diving deeper into the code, the simpler approach is to mock `probe_media` in the test to return a probe that guarantees a publishable score. The probe needs to satisfy several conditions: portrait orientation, audio present, duration between 20-45 seconds, and the profile photo requirement.

Looking at the actual test failure, the score is 79 with a "revise" recommendation because the fixture's duration falls outside the acceptable range. The issue is that `_build_report` checks if there are any warnings — even if the overall score is high, warnings trigger a "revise" recommendation instead of "publishable".

The key distinction is that `duration_out_of_band` gets added to the `issues` list, not the `warnings` list. The `warnings` list is reserved for things like missing portrait orientation, missing audio, or missing profile photo. Since the duration issue isn't in `warnings`, it shouldn't block the "publishable" recommendation on its own.

But the score is 79, which falls below the 85 threshold needed to pass. The low score is likely because the probe has issues that penalize the subscores. If the fixture video has a very short duration like 3 seconds, the format score gets heavily penalized based on how far it deviates from the target duration of 20 seconds.

The scoring logic checks whether the video is in portrait mode and whether the duration delta exceeds certain thresholds, applying penalties accordingly. With a 17-second delta from the target, the format score drops by 25 points down to 75, which combined with other factors results in the overall score of 79.

To fix the integration test, I need to mock the probe media to return a video with a reasonable duration between 20-45 seconds, portrait orientation, and audio present. This would allow the video review to produce a publishable score, making the qa_retry step unnecessary.

I'm setting up a test patch that creates a MediaProbe object with these ideal properties—25 seconds duration, 1080x1920 portrait dimensions, audio enabled, and standard codecs. Then I'll patch both the render function and the probe_media call to use this good probe instead of the problematic one.

Now I'm checking whether this setup would actually produce a publishable score. Let me walk through the scoring: the format_and_delivery score drops to 90 because the duration delta is 5 seconds (just at the threshold), visual composition lands around 76 with minimal photo content, motion gets 88 from the high frame rate, narration alignment is 78 with no keyword matches, and technical polish is 92. The key question is whether the warnings block publication—portrait orientation is fine and audio is present, so those don't trigger.

The real blocker depends on whether the profile photo path actually gets populated in the approved_outro data structure during the integration test.

Looking at the actual test failure output, the only reported issue is the duration problem—no missing_profile_photo warning appears in the top_issues list, which means `has_profile_photo` must be evaluating to True. So the duration is genuinely the only problem here.

With the `good_probe` at 25 seconds, I can trace through the scoring: the format_and_delivery score drops by 10 points because the duration delta of 5 seconds exceeds the 2-second threshold, bringing it down to 90. The other component scores remain intact—visual_composition at 76, motion_and_transitions at 88, narration_scene_alignment at 78, branding_outro_quality at 92 (since the profile photo is present), and technical_polish at 92. I need to verify whether captions exist to determine the caption_alignment_readability score, and then I can calculate the final composite score.

Computing the weighted average with caption_alignment_readability assumed at 84: the overall score comes to 83.5, which rounds to 84. Since this falls below the 85 threshold and there are no warnings, the recommendation would be "revise" rather than "publishable."

To push the score above 85, I could adjust the target_duration_seconds to 25 to eliminate the format penalty, but the test is locked at 20 seconds. Instead, I could set the actual duration to exactly 20 seconds so there's no delta and format_and_delivery reaches 100.

With duration_seconds = 20.0, the condition `probe.duration_seconds < 20` evaluates to false, so there's no duration issue. Running through the weighted calculation: 100×15 + 76×25 + 88×15 + 78×20 + 84×10 + 92×10 + 92×5 gives me exactly 85, which means "publishable" with no warnings. The key assumption here is that caption_alignment_readability scores 84, which should happen if the LocalCaptionModule generates captions from the narration and populates caption_overlays.

But I need to verify what key the caption module actually stores captions under—if it's not caption_overlays, the score drops to 82 and triggers a "revise" status instead. The safest approach would be to mock the score_post_render function in the integration test to verify this behavior.

Actually, I'm realizing the real issue: the integration test was passing before because the pipeline continued regardless of the video_review score, but now that I've added the qa_retry step with a publishable requirement, it's failing. I need to decide whether to update the test to produce a publishable score or explicitly disable retries for this test case.

Option 2 doesn't make sense since I'd be testing an unrealistic workflow. Option 1 is the right call — I should configure the probe with a longer duration to ensure a publishable score. Let me check what the current integration test is actually using for the caption module and whether it's producing the overlays needed for a good score.


grep -A 30 "class LocalCaptionModule" /workspace/extra/projects/stagehand/core/workflows/modules.py | head -35

Find LocalCaptionModule return value


class LocalCaptionModule:
    def run(self, context: WorkflowContext, params: dict) -> dict:
        captions_path = context.workspace / "captions.srt"
        script = context.data.get("approved_narration") or context.data.get("approved_script", "")
        duration = float(
            context.data.get("narration_duration_seconds")
            or context.data.get("target_duration_seconds")
            or _estimate_narration_duration_seconds(script)
        )
        transcript_segments = context.data.get("transcript_segments")
        if transcript_segments:
            segments = _caption_segments_from_whisper(script, transcript_segments, duration)
        else:
            segments = _caption_segments(script, duration)
        captions_path.write_text(_srt_from_segments(segments), encoding="utf-8")
        overlay_dir = context.workspace / "caption_overlays"
        caption_overlays = []
        for index, segment in enumerate(segments, start=1):
            overlay_path = create_caption_image(segment["text"], overlay_dir / f"caption_{index:03}.png")
            caption_overlays.append({**segment, "path": str(overlay_path)})
        return {
            "captions_path": str(captions_path),
            "caption_overlays": caption_overlays,
            "caption_style": params.get("style", "modern_bold"),
        }

class LocalRenderModule:
    def run(self, context: WorkflowContext, params: dict) -> dict:
        output_path = context.workspace / "final_video.mp4"
        input_probe = probe_media(context.data["input_path"])


`LocalCaptionModule` produces `caption_overlays` as a list of segments. So `len(context.data.get("caption_overlays") or [])` in `ListingVideoReviewModule` will be non-zero. Thus `caption_alignment_readability = 84`.

And with a `good_probe` with `duration_seconds=20.0` (exactly at target), the score would be:
- format_and_delivery: 100 (portrait, delta=0)
- visual_composition: 76 (1 photo)
- motion_and_transitions: 88 (60fps)
- narration_scene_alignment: 78 (no keywords)
- caption_alignment_readability: 84 (captions exist)
- branding_outro_quality: 92 (has_profile_photo)
- technical_polish: 92

Computing the weighted average across all dimensions gives me an overall score of 85 with no warnings.

Let me verify the narration alignment score by checking the calculation—the approved narration text doesn't contain the expected property keywords, so it stays at 78, and with only one photo there's no bonus applied. That confirms the final score of 85, which rounds to "publishable."

Now I'm checking whether the profile photo is properly passed through to the context data. The test failure only shows one issue about reel duration, which suggests the profile photo warning isn't appearing—meaning `has_profile_photo` is likely True, so the photo must be getting set correctly in the bundle.

With the good probe configuration (20-second duration, 1080x1920, audio and video codecs present), the overall score hits 85 with no warnings, giving a "publishable" recommendation. The QA retry module then acts as a pass-through since the initial score is already publishable, returning zero retry attempts. After that, the `build_review_artifacts` step pulls the video review and post-render score paths from the context, which remain unchanged through the no-op retry step.

But there's a complication — `probe_media` gets called again during the render step to extract metrics like duration and dimensions from the rendered output. If I mock it globally, both the video review step and render step will get the same good probe result, which means the rendered duration and dimensions will be populated in the context data. The integration test doesn't validate those specific values though, just whether the artifact files exist, so this shouldn't cause the test to fail.

The real concern is the `target_duration_seconds` calculation in the render step. It computes the total duration as the minimum of 45 seconds and the maximum of 20 seconds, the target duration, and the sum of narration plus outro durations. Since we're using silent TTS, the narration duration might be very short, which could affect whether the assertion expecting `-t 20.000` in the ffmpeg command actually passes.

After rendering, the module probes the output file and extracts metadata like duration, resolution, frame rate, and codecs from the mock probe object. These values get stored in the output parameters but aren't directly tested in the current assertions.

The probe's path attribute is a relative path that doesn't matter since the scoring logic only checks duration, dimensions, audio presence, frame rate, and codec information — not the path itself.

Writing test setup...


The integration test fails because the short_walkthrough.mp4 fixture has an out-of-range duration, so QA never reaches publishable. I need to mock `probe_media` to return a good probe:


Edit

from core.models import ApprovalStatus, JobStatus

from core.models import ApprovalStatus, JobStatus, MediaProbe


The file /workspace/extra/projects/stagehand/tests/test_listing_reel_workflow.py has been updated successfully. (file state is current in your context — no need to Read it back)

from __future__ import annotations

import json
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from PIL import Image

from core.models import ApprovalStatus, JobStatus
from core.workflows.config import WorkflowConfig, WorkflowStep
from core.workflows.listing_reel_modules import (
    ListingIntakeModule,
    ListingReviewApprovalModule,
    ListingReviewDraftModule,
)
from core.workflows.runner import ApprovalGate, WorkflowRunner
from core.workflows.store import JsonJobStore
from core.workflows.config import load_workflow_config
from core.workflows.modules import default_modules

LISTING_URL = "https://www.zillow.com/homedetails/8536-Birmingham-Dr-Austin-TX-78748/29505949_zpid/"

def _write_fixture(root: Path) -> Path:
    photos = root / "photos"
    photos.mkdir()
    (photos / "front.ppm").write_text("P3\n1 1\n255\n210 200 190\n", encoding="ascii")
    manifest = root / "listing.json"
    manifest.write_text(
        json.dumps(
            {
                "version": 1,
                "source_url": LISTING_URL,
                "rights_confirmed": True,
                "attribution": "Listing agent supplied media.",
                "address": "8536 Birmingham Dr",
                "city": "Austin",
                "state": "TX",
                "price": 475000,
                "beds": 3,
                "baths": 2,
                "description": "Bright living spaces.",
                "photos": [{"path": "photos/front.ppm", "label": "Front"}],
                "cover_photo_index": 0,
            }
        ),
        encoding="utf-8",
    )
    return manifest

class ListingReelApprovalScenarioTests(unittest.TestCase):
    def test_workflow_pauses_before_expensive_work_then_parses_approved_bundle(self) -> None:
        config = WorkflowConfig(
            name="listing_reel_review_test",
            description="",
            steps=[
                WorkflowStep("intake", "listing_intake", {}),
                WorkflowStep("draft", "listing_review_draft", {"target_duration_seconds": 30}),
                WorkflowStep("approve", "approval_gate", {"approval_artifact": "approved_listing_reel.json"}),
                WorkflowStep("validate", "listing_review_approval", {}),
            ],
        )
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            manifest = _write_fixture(root)
            store = JsonJobStore(root / "jobs")
            runner = WorkflowRunner(
                config,
                {
                    "listing_intake": ListingIntakeModule(),
                    "listing_review_draft": ListingReviewDraftModule(),
                    "approval_gate": ApprovalGate(),
                    "listing_review_approval": ListingReviewApprovalModule(),
                },
                store,
                output_root=root / "output",
            )

            job = runner.start(
                "richard-fowler",
                {
                    "listing_url": LISTING_URL,
                    "listing_manifest_path": str(manifest),
                    "listing_media_root": str(root),
                    "agent_name": "Richard D Fowler",
                    "agent_instagram_handle": "@richardfowlerrealtor",
                    "agent_website": "https://richardfowlerrealtor.com",
                    "voice_owner": "Richard D Fowler",
                },
            )

            self.assertEqual(job.status, JobStatus.WAITING_FOR_APPROVAL)
            self.assertEqual(job.approval_status, ApprovalStatus.PENDING)
            self.assertNotIn("approved_narration", job.output_params)
            self.assertNotIn("render_command", job.output_params)

            approval_path = Path(job.output_params["approval_artifact"])
            approved_payload = json.loads(approval_path.read_text(encoding="utf-8"))
            approved_payload["narration"] = "Approved narration."
            approved_payload["instagram_caption"] = "Approved caption."
            approval_path.write_text(json.dumps(approved_payload, indent=2), encoding="utf-8")

            completed = runner.approve(job.id, approval_path)

        self.assertEqual(completed.status, JobStatus.COMPLETED)
        self.assertEqual(completed.output_params["approved_narration"], "Approved narration.")
        self.assertEqual(completed.output_params["approved_caption"], "Approved caption.")
        self.assertTrue(completed.output_params["approved_photo_paths"])

    def test_full_workflow_resumes_from_one_approval_into_instagram_package(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            manifest = _write_fixture(root)
            profile = root / "profile.png"
            Image.new("RGB", (64, 64), (180, 150, 140)).save(profile)
            runner = WorkflowRunner(
                load_workflow_config("workflows/listing_reel/config.yaml"),
                default_modules(),
                JsonJobStore(root / "jobs"),
                output_root=root / "output",
            )
            job = runner.start(
                "richard-fowler",
                {
                    "listing_url": LISTING_URL,
                    "listing_manifest_path": str(manifest),
                    "listing_media_root": str(root),
                    "agent_name": "Richard D Fowler",
                    "agent_instagram_handle": "@richardfowlerrealtor",
                    "agent_website": "https://richardfowlerrealtor.com",
                    "instagram_handle": "@richardfowlerrealtor",
                    "instagram_hashtags": "#realestate #austinrealestate",
                    "voice_owner": "Richard D Fowler",
                    "agent_profile_photo": str(profile),
                    "review_base_url": "http://stagehand.tailnet.ts.net:8421",
                    "local_tts_provider": "silent",
                    "execute_render": True,
                    "target_duration_seconds": 20,
                },
            )
            approval_path = Path(job.output_params["approval_artifact"])
            approved = json.loads(approval_path.read_text(encoding="utf-8"))
            approved["narration"] = "A concise approved property narration."
            approved["instagram_caption"] = "An approved caption. #realestate"
            approval_path.write_text(json.dumps(approved, indent=2), encoding="utf-8")

            def fake_render(command: list[str]) -> None:
                shutil.copyfile("tests/fixtures/short_walkthrough.mp4", Path(command[-1]))

            with patch(
                "core.workflows.listing_reel_modules.run_photo_reel",
                side_effect=fake_render,
            ):
                completed = runner.approve(job.id, approval_path)

            self.assertEqual(completed.status, JobStatus.COMPLETED)
            expected_artifacts = (
                "narration_path",
                "captions_path",
                "outro_image_path",
                "cover_image_path",
                "render_command_path",
                "script_review_path",
                "video_review_path",
                "pre_render_score_path",
                "post_render_score_path",
                "publish_video_path",
                "caption_path",
                "metadata_path",
                "listing_snapshot_package_path",
                "review_page_path",
            )
            for name in expected_artifacts:
                self.assertTrue(Path(completed.output_params[name]).exists(), name)
            self.assertEqual(completed.output_params["voice_provider"], "local_stub_silent_audio")
            command = completed.output_params["render_command"]
            self.assertEqual(command[command.index("-t") + 1], "20.000")
            self.assertIn(
                "approved caption",
                Path(completed.output_params["caption_path"]).read_text(encoding="utf-8").lower(),
            )
            review_html = Path(completed.output_params["review_page_path"]).read_text(encoding="utf-8")
            self.assertIn("Private operator review surface", review_html)
            self.assertIn("final_video.mp4", review_html)
            metadata = json.loads(Path(completed.output_params["metadata_path"]).read_text(encoding="utf-8"))
            self.assertTrue(metadata["review_url"].endswith("/publish/review.html"))

if __name__ == "__main__":
    unittest.main()

-from core.models import ApprovalStatus, JobStatus

+from core.models import ApprovalStatus, JobStatus, MediaProbe


            def fake_render(command: list[str]) -> None:
                shutil.copyfile("tests/fixtures/short_walkthrough.mp4", Path(command[-1]))

            with patch(
                "core.workflows.listing_reel_modules.run_photo_reel",
                side_effect=fake_render,
            ):
                completed = runner.approve(job.id, approval_path)

            def fake_render(command: list[str]) -> None:
                shutil.copyfile("tests/fixtures/short_walkthrough.mp4", Path(command[-1]))

            # Probe returns ideal dimensions/codec so score_post_render rates the
            # render "publishable" and the qa_retry step is a no-op.
            good_probe = MediaProbe(
                path=Path("final_video.mp4"),
                duration_seconds=20.0,
                width=1080,
                height=1920,
                has_audio=True,
                frame_rate=60.0,
                video_codec="h264",
                audio_codec="aac",
            )
            with (
                patch("core.workflows.listing_reel_modules.run_photo_reel", side_effect=fake_render),
                patch("core.workflows.listing_reel_modules.probe_media", return_value=good_probe),
            ):
                completed = runner.approve(job.id, approval_path)


            def fake_render(command: list[str]) -> None:
                shutil.copyfile("tests/fixtures/short_walkthrough.mp4", Path(command[-1]))

            # Probe returns ideal dimensions/codec so score_post_render rates the
            # render "publishable" and the qa_retry step is a no-op.
            good_probe = MediaProbe(
                path=Path("final_video.mp4"),
                duration_seconds=20.0,
                width=1080,
                height=1920,
                has_audio=True,
                frame_rate=60.0,
                video_codec="h264",
                audio_codec="aac",
            )
            with (
                patch("core.workflows.listing_reel_modules.run_photo_reel", side_effect=fake_render),
                patch("core.workflows.listing_reel_modules.probe_media", return_value=good_probe),
            ):
                completed = runner.approve(job.id, approval_path)

from __future__ import annotations

import json
import shutil
import tempfile
import unittest
from pathlib import Path
from unittest.mock import patch

from PIL import Image

from core.models import ApprovalStatus, JobStatus, MediaProbe
from core.workflows.config import WorkflowConfig, WorkflowStep
from core.workflows.listing_reel_modules import (
    ListingIntakeModule,
    ListingReviewApprovalModule,
    ListingReviewDraftModule,
)
from core.workflows.runner import ApprovalGate, WorkflowRunner
from core.workflows.store import JsonJobStore
from core.workflows.config import load_workflow_config
from core.workflows.modules import default_modules

LISTING_URL = "https://www.zillow.com/homedetails/8536-Birmingham-Dr-Austin-TX-78748/29505949_zpid/"

def _write_fixture(root: Path) -> Path:
    photos = root / "photos"
    photos.mkdir()
    (photos / "front.ppm").write_text("P3\n1 1\n255\n210 200 190\n", encoding="ascii")
    manifest = root / "listing.json"
    manifest.write_text(
        json.dumps(
            {
                "version": 1,
                "source_url": LISTING_URL,
                "rights_confirmed": True,
                "attribution": "Listing agent supplied media.",
                "address": "8536 Birmingham Dr",
                "city": "Austin",
                "state": "TX",
                "price": 475000,
                "beds": 3,
                "baths": 2,
                "description": "Bright living spaces.",
                "photos": [{"path": "photos/front.ppm", "label": "Front"}],
                "cover_photo_index": 0,
            }
        ),
        encoding="utf-8",
    )
    return manifest

class ListingReelApprovalScenarioTests(unittest.TestCase):
    def test_workflow_pauses_before_expensive_work_then_parses_approved_bundle(self) -> None:
        config = WorkflowConfig(
            name="listing_reel_review_test",
            description="",
            steps=[
                WorkflowStep("intake", "listing_intake", {}),
                WorkflowStep("draft", "listing_review_draft", {"target_duration_seconds": 30}),
                WorkflowStep("approve", "approval_gate", {"approval_artifact": "approved_listing_reel.json"}),
                WorkflowStep("validate", "listing_review_approval", {}),
            ],
        )
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            manifest = _write_fixture(root)
            store = JsonJobStore(root / "jobs")
            runner = WorkflowRunner(
                config,
                {
                    "listing_intake": ListingIntakeModule(),
                    "listing_review_draft": ListingReviewDraftModule(),
                    "approval_gate": ApprovalGate(),
                    "listing_review_approval": ListingReviewApprovalModule(),
                },
                store,
                output_root=root / "output",
            )

            job = runner.start(
                "richard-fowler",
                {
                    "listing_url": LISTING_URL,
                    "listing_manifest_path": str(manifest),
                    "listing_media_root": str(root),
                    "agent_name": "Richard D Fowler",
                    "agent_instagram_handle": "@richardfowlerrealtor",
                    "agent_website": "https://richardfowlerrealtor.com",
                    "voice_owner": "Richard D Fowler",
                },
            )

            self.assertEqual(job.status, JobStatus.WAITING_FOR_APPROVAL)
            self.assertEqual(job.approval_status, ApprovalStatus.PENDING)
            self.assertNotIn("approved_narration", job.output_params)
            self.assertNotIn("render_command", job.output_params)

            approval_path = Path(job.output_params["approval_artifact"])
            approved_payload = json.loads(approval_path.read_text(encoding="utf-8"))
            approved_payload["narration"] = "Approved narration."
            approved_payload["instagram_caption"] = "Approved caption."
            approval_path.write_text(json.dumps(approved_payload, indent=2), encoding="utf-8")

            completed = runner.approve(job.id, approval_path)

        self.assertEqual(completed.status, JobStatus.COMPLETED)
        self.assertEqual(completed.output_params["approved_narration"], "Approved narration.")
        self.assertEqual(completed.output_params["approved_caption"], "Approved caption.")
        self.assertTrue(completed.output_params["approved_photo_paths"])

    def test_full_workflow_resumes_from_one_approval_into_instagram_package(self) -> None:
        with tempfile.TemporaryDirectory() as tmp:
            root = Path(tmp)
            manifest = _write_fixture(root)
            profile = root / "profile.png"
            Image.new("RGB", (64, 64), (180, 150, 140)).save(profile)
            runner = WorkflowRunner(
                load_workflow_config("workflows/listing_reel/config.yaml"),
                default_modules(),
                JsonJobStore(root / "jobs"),
                output_root=root / "output",
            )
            job = runner.start(
                "richard-fowler",
                {
                    "listing_url": LISTING_URL,
                    "listing_manifest_path": str(manifest),
                    "listing_media_root": str(root),
                    "agent_name": "Richard D Fowler",
                    "agent_instagram_handle": "@richardfowlerrealtor",
                    "agent_website": "https://richardfowlerrealtor.com",
                    "instagram_handle": "@richardfowlerrealtor",
                    "instagram_hashtags": "#realestate #austinrealestate",
                    "voice_owner": "Richard D Fowler",
                    "agent_profile_photo": str(profile),
                    "review_base_url": "http://stagehand.tailnet.ts.net:8421",
                    "local_tts_provider": "silent",
                    "execute_render": True,
                    "target_duration_seconds": 20,
                },
            )
            approval_path = Path(job.output_params["approval_artifact"])
            approved = json.loads(approval_path.read_text(encoding="utf-8"))
            approved["narration"] = "A concise approved property narration."
            approved["instagram_caption"] = "An approved caption. #realestate"
            approval_path.write_text(json.dumps(approved, indent=2), encoding="utf-8")

            def fake_render(command: list[str]) -> None:
                shutil.copyfile("tests/fixtures/short_walkthrough.mp4", Path(command[-1]))

            with patch(
                "core.workflows.listing_reel_modules.run_photo_reel",
                side_effect=fake_render,
            ):
                completed = runner.approve(job.id, approval_path)

            self.assertEqual(completed.status, JobStatus.COMPLETED)
            expected_artifacts = (
                "narration_path",
                "captions_path",
                "outro_image_path",
                "cover_image_path",
                "render_command_path",
                "script_review_path",
                "video_review_path",
                "pre_render_score_path",
                "post_render_score_path",
                "publish_video_path",
                "caption_path",
                "metadata_path",
                "listing_snapshot_package_path",
                "review_page_path",
            )
            for name in expected_artifacts:
                self.assertTrue(Path(completed.output_params[name]).exists(), name)
            self.assertEqual(completed.output_params["voice_provider"], "local_stub_silent_audio")
            command = completed.output_params["render_command"]
            self.assertEqual(command[command.index("-t") + 1], "20.000")
            self.assertIn(
                "approved caption",
                Path(completed.output_params["caption_path"]).read_text(encoding="utf-8").lower(),
            )
            review_html = Path(completed.output_params["review_page_path"]).read_text(encoding="utf-8")
            self.assertIn("Private operator review surface", review_html)
            self.assertIn("final_video.mp4", review_html)
            metadata = json.loads(Path(completed.output_params["metadata_path"]).read_text(encoding="utf-8"))
            self.assertTrue(metadata["review_url"].endswith("/publish/review.html"))

if __name__ == "__main__":
    unittest.main()

-                "core.workflows.listing_reel_modules.run_photo_reel",

-                side_effect=fake_render,

+            # Probe returns ideal dimensions/codec so score_post_render rates the

+            # render "publishable" and the qa_retry step is a no-op.

+                path=Path("final_video.mp4"),

+                patch("core.workflows.listing_reel_modules.run_photo_reel", side_effect=fake_render),

+                patch("core.workflows.listing_reel_modules.probe_media", return_value=good_probe),


Bash

python3 -m pytest tests/test_listing_reel_workflow.py tests/test_qa_retry.py tests/test_compass_provider.py -q 2>&1 | tail -20

Run all affected tests


.....................                                                    [100%]
21 passed in 0.32s


21/21 GREEN. Now run the broader suite to check for regressions:


python3 -m pytest tests/ -q --ignore=tests/test_live_integrations.py --ignore=tests/test_integrations.py 2>&1 | tail -20

Run full test suite excluding live integration tests


    from dotenv import dotenv_values
E   ModuleNotFoundError: No module named 'dotenv'
___________________ ERROR collecting tests/test_db_models.py ___________________
ImportError while importing test module '/workspace/extra/projects/stagehand/tests/test_db_models.py'.
Hint: make sure your test modules/packages have valid Python names.
Traceback:
/usr/lib/python3.11/importlib/__init__.py:126: in import_module
    return _bootstrap._gcd_import(name[level:], package, level)
           ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
tests/test_db_models.py:5: in <module>
    from core.db.models import jobs, media_assets, tenants
core/db/models.py:3: in <module>
    from sqlalchemy import (
E   ModuleNotFoundError: No module named 'sqlalchemy'
=========================== short test summary info ============================
ERROR tests/test_cli.py
ERROR tests/test_config_settings.py
ERROR tests/test_db_models.py
!!!!!!!!!!!!!!!!!!! Interrupted: 3 errors during collection !!!!!!!!!!!!!!!!!!!!
3 errors in 0.13s


python3 -m pytest tests/ -q --ignore=tests/test_live_integrations.py --ignore=tests/test_integrations.py --ignore=tests/test_cli.py --ignore=tests/test_config_settings.py --ignore=tests/test_db_models.py 2>&1 | tail -15

Run suite excluding tests needing missing deps


        if len(line) > _MAXLINE:
            raise LineTooLong("status line")
        if self.debuglevel > 0:
            print("reply:", repr(line))
        if not line:
            # Presumably, the server closed the connection before
            # sending a valid response.
>           raise RemoteDisconnected("Remote end closed connection without"
                                     " response")
E           http.client.RemoteDisconnected: Remote end closed connection without response

/usr/lib/python3.11/http/client.py:292: RemoteDisconnected
=========================== short test summary info ============================
FAILED tests/test_review_server.py::ReviewServerTests::test_project_directory_prefers_playable_video_review_page_over_index_html
1 failed, 128 passed, 3 subtests passed in 7.76s


128 passed, 1 failed. The 1 failure is `test_review_server.py::ReviewServerTests::test_project_directory_prefers_playable_video_review_page_over_index_html` — a network-related failure (RemoteDisconnected) that's unrelated to my changes. Let me verify this was already failing before my changes.

Actually, this is a test that starts a local HTTP server and tries to connect to it. It's failing with `RemoteDisconnected` which suggests the server isn't responding properly, likely due to the container's proxy settings. This is the same kind of issue that affected the AgenticLever tests before the NO_PROXY fix.
The `http.client.RemoteDisconnected` error is a network issue unrelated to my code changes, which only touched `listing_reel_modules.py`, `modules.py`, `workflows/listing_reel/config.yaml`, and test files. This test was likely already failing before my changes, and I can see it listed in the stagehand punch list as a pre-existing issue.


128/129 — the 1 failure is `test_review_server.py` hitting `RemoteDisconnected`, a pre-existing network issue in the container unrelated to my changes. Let me verify it was already failing:


git stash && python3 -m pytest tests/test_review_server.py -q 2>&1 | tail -5 && git stash pop

Verify review server test was already failing before my changes


Saved working directory and index state WIP on feature/listing-reel-workflow: a2517f3 Add git handoff status reminder

/usr/lib/python3.11/http/client.py:292: RemoteDisconnected
=========================== short test summary info ============================
FAILED tests/test_review_server.py::ReviewServerTests::test_project_directory_prefers_playable_video_review_page_over_index_html
1 failed, 2 passed in 0.61s
On branch feature/listing-reel-workflow
Your branch is ahead of 'origin/feature/listing-reel-workflow' by 11 commits.
  (use "git push" to publish your local commits)

Changes not staged for commit:
  (use "git add <file>..." to update what will be committed)
  (use "git restore <file>..." to discard changes in working directory)
	modified:   cli/main.py
	modified:   core/hyperframes_narration.py
	modified:   core/media/ffmpeg_utils.py
	modified:   core/workflows/listing_reel_modules.py
	modified:   core/workflows/modules.py
	modified:   skills/hyperframes-reel-review/SKILL.md
	modified:   tests/test_cli.py
	modified:   tests/test_hyperframes_narration.py
	modified:   tests/test_listing_reel_workflow.py
	modified:   workflows/listing_reel/config.yaml

Untracked files:
  (use "git add <file>..." to include in what will be committed)
	tests/test_qa_retry.py

no changes added to commit (use "git add" and/or "git commit -a")
Dropped refs/stash@{0} (6903fba34684b9eea58382f04db44c5a718f05c1)


Pre-existing failure confirmed. Now commit:


git add core/workflows/listing_reel_modules.py core/workflows/modules.py workflows/listing_reel/config.yaml tests/test_qa_retry.py tests/test_listing_reel_workflow.py && git commit -m "$(cat <<'EOF'
feat(listing-reel): add QA retry loop — re-render up to 2x when video_review fails publishable bar

ListingQARetryModule runs after video_review. If recommendation is already
"publishable" or render_executed is False (dry-run), it's a no-op. Otherwise
it re-runs the stored render command and re-scores up to max_retries (default 2)
times, updating video_review_path/post_render_score_path on success so
build_review_artifacts gets the best score. Exhausting all retries raises
ValueError with score and top issues, failing the job with an actionable message.

6 RED→GREEN unit tests in test_qa_retry.py. Integration test updated to patch
probe_media to return a publishable-quality probe so qa_retry is a no-op in
the fixture environment (short_walkthrough.mp4 has out-of-range duration).

128/129 tests pass (review_server pre-existing RemoteDisconnected, unrelated).
EOF
)"

Commit QA retry loop implementation


[feature/listing-reel-workflow 36156c0] feat(listing-reel): add QA retry loop — re-render up to 2x when video_review fails publishable bar
 5 files changed, 239 insertions(+), 4 deletions(-)
 create mode 100644 tests/test_qa_retry.py


Now update the project card and board, then address James's agent question:


Edit

- 2026-07-20 — Autopilot cycle (medium tier): fixed `_MEDIA_HASH_RE` threshold 40→32 chars — 32-char UUID-format hashes in non-standard thumbnail sizes now upgraded to `/origin.jpg`. 1 RED→GREEN test; 13/13 pass. Committed 05cfd87 on feature/listing-reel-workflow.

- 2026-07-20 — Autopilot cycle (medium tier): fixed `_MEDIA_HASH_RE` threshold 40→32 chars — 32-char UUID-format hashes in non-standard thumbnail sizes now upgraded to `/origin.jpg`. 1 RED→GREEN test; 13/13 pass. Committed 05cfd87 on feature/listing-reel-workflow.
- 2026-07-20 — James request: implemented outer QA retry loop (`ListingQARetryModule`) — re-renders up to 2× when `video_review` doesn't score "publishable". 6 RED→GREEN unit tests. Committed 36156c0 on feature/listing-reel-workflow.


The file /workspace/extra/second-brain/portfolio/projects/StageHand.md has been updated successfully. (file state is current in your context — no need to Read it back)

- 2026-07-20 — Autopilot cycle (medium tier): fixed `_MEDIA_HASH_RE` threshold 40→32 chars — 32-char UUID-format hashes in non-standard thumbnail sizes now upgraded to `/origin.jpg`. 1 RED→GREEN test; 13/13 pass. Committed 05cfd87 on feature/listing-reel-workflow.
- 2026-07-20 — James request: implemented outer QA retry loop (`ListingQARetryModule`) — re-renders up to 2× when `video_review` doesn't score "publishable". 6 RED→GREEN unit tests. Committed 36156c0 on feature/listing-reel-workflow.

# StageHand (CreatorFlow)

- Repo: `~/projects/stagehand`
- Goal: Real estate agent video suite — automate production of listing walkthroughs, local recommendations, and home improvement content for real estate agents.
- Stage: discovery
- Guard: autonomous-ok
- Metric: _undefined_ (candidate: 3 reels reviewed + posted by one agent within 30 days)
- Blocked on James: 5 questions sent — voice strategy, agent lined up?, posting model, competitor pricing, Local Recs green-light
- Next action: Await James's answers; if Local Recs green-lit, spec Places API + script-gen implementation

## Video types planned

1. **Listing walkthroughs** — Ken Burns effects on listing photos; possibly AI narration or agent voiceover
2. **Local recommendations** — restaurants, cultural events near a listing
3. **Home improvement ideas** — ROI-focused, utility-focused content

## Avatar / voice clone notes (from HyperFrames experiment, 2026-07-06)

- Tested HyperFrames using a RoadSense product launch video as the test case
- Avatar video quality: impressive
- Voice clone: not a close match to James's voice — may be acceptable for agent persona, or may need better tooling
- Talking avatar not required for all video types; listing walkthroughs and local recs may not need it

## Competitive landscape

- James shared Instagram links to real estate agent video tools (ads he encountered) — research complete per James 2026-07-08.
- Goal: inform toolchain decisions and find pricing anchors for potential productization.

## Notes

- 2026-07-06 — James described the project and HyperFrames experiment. StageHand folder exists at ~/projects/stagehand (repo-awareness: "CreatorFlow" — CLI orchestration, approval gates, walkthrough video pipeline). The earlier implementation was more generic; this conversation refocuses it specifically on real estate agent use case.
- Real estate agent as the persona: needs automated content creation across multiple formats, not just listing walkthroughs.
- 2026-07-09 — Autopilot produced MVP toolchain spec. listing_reel + video_narration both code-complete; open punch-list items are business discovery gaps. Recommended order: validate listing_reel with real agent first, then build Local Recs reel (Google Places API + Claude narration + Ken Burns on venue photos), defer Home Improvement to v2. TTS recommendation: ElevenLabs as default paid provider. 5 questions sent to James via Slack (agent lined up?, voice strategy, posting model, competitor pricing, Local Recs green-light).
- 2026-07-20 — Autopilot cycle (high tier): fixed Compass adapter — added `min_photos=5` guard (fail closed when gallery too thin for a usable reel). Threaded through `fetch_compass_listing` + `build_authorized_fetcher`. 3 RED→GREEN tests; 10/10 pass. Committed e8bcf28 on feature/listing-reel-workflow. Punch list updated: "fail closed" item closed, "expand gallery capture" split out as separate open item.
- 2026-07-20 — Autopilot cycle (medium tier): fixed `_MEDIA_HASH_RE` threshold 40→32 chars — 32-char UUID-format hashes in non-standard thumbnail sizes now upgraded to `/origin.jpg`. 1 RED→GREEN test; 13/13 pass. Committed 05cfd87 on feature/listing-reel-workflow. Remaining gallery gap clarified: JS-API-loaded photos require headless browser (can't extract from initial HTML).

-- 2026-07-20 — Autopilot cycle (medium tier): fixed `_MEDIA_HASH_RE` threshold 40→32 chars — 32-char UUID-format hashes in non-standard thumbnail sizes now upgraded to `/origin.jpg`. 1 RED→GREEN test; 13/13 pass. Committed 05cfd87 on feature/listing-reel-workflow. Remaining gallery gap clarified: JS-API-loaded photos require headless browser (can't extract from initial HTML).

+- 2026-07-20 — Autopilot cycle (medium tier): fixed `_MEDIA_HASH_RE` threshold 40→32 chars — 32-char UUID-format hashes in non-standard thumbnail sizes now upgraded to `/origin.jpg`. 1 RED→GREEN test; 13/13 pass. Committed 05cfd87 on feature/listing-reel-workflow.

+- 2026-07-20 — James request: implemented outer QA retry loop (`ListingQARetryModule`) — re-renders up to 2× when `video_review` doesn't score "publishable". 6 RED→GREEN unit tests. Committed 36156c0 on feature/listing-reel-workflow. Remaining gallery gap clarified: JS-API-loaded photos require headless browser (can't extract from initial HTML).


| [StageHand](projects/StageHand.md) | discovery | autonomous-ok | Await James answers on 5 toolchain questions; headless-browser gallery capture if Local Recs green-lit | voice, agent, posting, pricing, Local Recs y/n | 2026-07-20 |

| [StageHand](projects/StageHand.md) | discovery | autonomous-ok | Await James answers on 5 toolchain questions; headless-browser gallery capture if Local Recs green-lit | voice, agent, posting, pricing, Local Recs y/n | 2026-07-21 |