feat: improve operator progress and recording accuracy
This commit is contained in:
1 parent
58210106d7
commit
4bc79fdec8
15 files changed
+773
-224
No files matched your search
@@ -185,6 +185,45 @@ class PlanSnapshot:
|
||||
parent = candidate
|
||||
return parent
|
||||
|
||||
def children_of(self, item: PlanItem) -> tuple[PlanItem, ...]:
|
||||
"""Nested items under a top-level item (up to the next top-level line)."""
|
||||
if not item.is_top_level:
|
||||
return ()
|
||||
children: list[PlanItem] = []
|
||||
for candidate in self.items:
|
||||
if candidate.line_no <= item.line_no:
|
||||
continue
|
||||
if candidate.is_top_level:
|
||||
break
|
||||
children.append(candidate)
|
||||
return tuple(children)
|
||||
|
||||
def active_milestone(self) -> PlanItem | None:
|
||||
"""The milestone currently being executed.
|
||||
|
||||
An explicitly active top-level item wins; otherwise the parent of the
|
||||
bottom-most active nested item; otherwise, when every item is still
|
||||
untouched, the first pending milestone (the same fallback the step
|
||||
stamping uses). ``None`` when nothing is in progress.
|
||||
"""
|
||||
for item in self.top_level:
|
||||
if item.is_active:
|
||||
return item
|
||||
nested_active = self.last_active()
|
||||
if nested_active is not None:
|
||||
return self.parent_of(nested_active)
|
||||
if self.top_level and all(item.is_pending for item in self.top_level):
|
||||
return self.top_level[0]
|
||||
return None
|
||||
|
||||
def active_leaf(self, milestone: PlanItem) -> PlanItem | None:
|
||||
"""Bottom-most ``[/]`` nested item under ``milestone`` (the current sub-goal)."""
|
||||
leaf = None
|
||||
for child in self.children_of(milestone):
|
||||
if child.is_active:
|
||||
leaf = child
|
||||
return leaf
|
||||
|
||||
def check_items_of(self, item: PlanItem) -> tuple[CheckItem, ...]:
|
||||
"""Check lines anchored to the given top-level subgoal."""
|
||||
return tuple(ci for ci in self.check_items if ci.parent_key == item.key)
|
||||
@@ -394,7 +433,7 @@ def unintended_milestone_edits(before: PlanSnapshot, after: PlanSnapshot) -> lis
|
||||
_PLAN_GRAMMAR_BASE = f"""### Task Plan Grammar (machine-enforced contract)
|
||||
Every checklist line MUST match `<indent>- [<status>] <text>`:
|
||||
- Status characters: `[{STATUS_PENDING}]` pending, `[{STATUS_ACTIVE}]` in progress, `[{STATUS_DONE}]` completed, `[{STATUS_BLOCKED}]` blocked.
|
||||
- Top-level lines (no indentation) are strategic milestones; their sub-tasks are indented by 2 spaces. All other text in the note is free-form context.
|
||||
- Top-level lines (no indentation) are strategic milestones. Nested lines are their sub-goals: 2 spaces for a sub-goal, 4 spaces for a sub-sub-goal beneath it (deeper nesting is allowed). Only zero-indent lines count as milestones for termination and validation; nested lines are the executor's live ledger. All other text in the note is free-form context.
|
||||
- `{LOOP_TAG}` tags a BOUNDED iterative milestone: declare its exit boundary (e.g., `(Exit: <condition>; Interval: <cadence>)`) and mark it `[{STATUS_DONE}]` only once that exit condition is verifiably met.
|
||||
- `{CONTINUOUS_LOOP_TAG}` tags an UNBOUNDED continuous-monitoring milestone: it must stay `[{STATUS_ACTIVE}]` and can never be marked `[{STATUS_DONE}]`, deleted, or untagged by you — the system mechanically rejects such edits. Only an explicit external stop signal injected by the user unlocks its completion; that signal is an external interruption delivered by the system, never something inferred from the screen or the plan.
|
||||
- The task terminates only when every top-level milestone is `[{STATUS_DONE}]`."""
|
||||
|
||||
@@ -825,11 +825,13 @@ def build_plan_and_history(
|
||||
|
||||
|
||||
def get_active_subgoal_hashes(task_plan: str) -> tuple[str, str | None]:
|
||||
"""Parses task_plan content to find the active top-level subgoal hash.
|
||||
"""Parses task_plan content to find the active (top-level, nested) subgoal hashes.
|
||||
|
||||
Consolidates all active subtasks under their parent Level 1 subgoal,
|
||||
returning (parent_hash, None). Falls back to the first pending subgoal if no
|
||||
active subgoal is found and all are pending.
|
||||
Returns ``(parent_hash, sub_hash)``: the parent is the active top-level
|
||||
milestone (all active nested items consolidate under it); the sub hash is
|
||||
the bottom-most active nested item — the live sub-goal ledger leaf — or
|
||||
``None`` when the milestone has no in-progress sub-goal. Falls back to the
|
||||
first pending subgoal if no active subgoal is found and all are pending.
|
||||
"""
|
||||
if not task_plan:
|
||||
return "default", None
|
||||
@@ -843,7 +845,7 @@ def get_active_subgoal_hashes(task_plan: str) -> tuple[str, str | None]:
|
||||
if not active_item.is_top_level:
|
||||
parent = snapshot.parent_of(active_item)
|
||||
if parent is not None:
|
||||
return parent.key, None
|
||||
return parent.key, active_item.key
|
||||
return active_item.key, None
|
||||
|
||||
# No active subgoal found. Check if all are completed.
|
||||
|
||||
@@ -24,6 +24,7 @@ from pathlib import Path
|
||||
import platform
|
||||
import re
|
||||
import shutil
|
||||
import time
|
||||
import subprocess
|
||||
from typing import Any
|
||||
from uuid import UUID
|
||||
@@ -37,6 +38,11 @@ logger = get_logger(__name__)
|
||||
|
||||
DEFAULT_MAX_DURATION_SECONDS = 900 # 15 minutes
|
||||
ANDROID_RECORDING_SEGMENT_SECONDS = 1800
|
||||
# The recording marker closely tracks the first frame; startup measurements
|
||||
# put the fallback near three quarters of a second after process creation.
|
||||
SCRCPY_RECORDING_STARTED_MARKER = "Recording started"
|
||||
SCRCPY_STARTUP_FALLBACK_SECONDS = 0.75
|
||||
SCRCPY_STARTUP_TIMEOUT_SECONDS = 6.0
|
||||
VIDEO_READY_DELAY_SECONDS = 1
|
||||
ANDROID_DEVICE_VIDEO_PATH = "/sdcard/screen_recording.mp4"
|
||||
ANDROID_MAX_RECORDING_DURATION_SECONDS = 180 # Android screenrecord limit
|
||||
@@ -78,6 +84,43 @@ def build_scrcpy_record_command(
|
||||
return command
|
||||
|
||||
|
||||
async def await_scrcpy_first_frame(
|
||||
process: Any,
|
||||
spawned_at: float,
|
||||
*,
|
||||
timeout: float = SCRCPY_STARTUP_TIMEOUT_SECONDS,
|
||||
) -> float:
|
||||
"""Estimate when the first frame of a scrcpy recording reached the host."""
|
||||
fallback = spawned_at + SCRCPY_STARTUP_FALLBACK_SECONDS
|
||||
reader = getattr(process, "stdout", None)
|
||||
if not isinstance(reader, asyncio.StreamReader):
|
||||
await asyncio.sleep(0.8)
|
||||
return fallback
|
||||
|
||||
deadline = time.monotonic() + timeout
|
||||
while True:
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
logger.warning(
|
||||
f"scrcpy did not report '{SCRCPY_RECORDING_STARTED_MARKER}' within "
|
||||
f"{timeout:.1f}s; assuming the first frame at spawn + "
|
||||
f"{SCRCPY_STARTUP_FALLBACK_SECONDS:.2f}s"
|
||||
)
|
||||
return fallback
|
||||
try:
|
||||
line = await asyncio.wait_for(reader.readline(), timeout=remaining)
|
||||
except TimeoutError:
|
||||
continue
|
||||
if not line:
|
||||
try:
|
||||
await asyncio.wait_for(process.wait(), timeout=1.0)
|
||||
except (AttributeError, ProcessLookupError, TimeoutError):
|
||||
pass
|
||||
return fallback
|
||||
if SCRCPY_RECORDING_STARTED_MARKER in line.decode(errors="replace"):
|
||||
return max(spawned_at, time.time())
|
||||
|
||||
|
||||
class _CyFunctionDetectorMeta(type):
|
||||
def __instancecheck__(self, instance):
|
||||
name = type(instance).__name__
|
||||
|
||||
Reference in new issue
Block a user