diff --git a/.agents/shared/test-loop.md b/.agents/shared/test-loop.md index 72d9712b3e..abf22d23d6 100644 --- a/.agents/shared/test-loop.md +++ b/.agents/shared/test-loop.md @@ -81,7 +81,7 @@ and debugger. `GREEN_REF` to the resulting commit. - **Test report** (`test.md`) is the only fix-agent handoff. Give it the latest Attempt/Run section, especially Root cause / Fix hint and Failure signature. Reserve wrapper-owned `result.md` for the - terminal task result; never create `result.md`. + published attempt-boundary result; never create `result.md`. ## Commit message diff --git a/.agents/skills/continue/SKILL.md b/.agents/skills/continue/SKILL.md index 889c0740ad..99472f227a 100644 --- a/.agents/skills/continue/SKILL.md +++ b/.agents/skills/continue/SKILL.md @@ -1,6 +1,6 @@ --- name: continue -description: Continue autonomous Telegram Desktop development from the shared ai-tdesktop repository. Use when the user invokes $continue or /continue, asks Codex to keep working through the AI queue, or wants one command to process the local inbox, resume this checkout's active task, consume its claimed queue, and claim new work until nothing eligible remains. +description: Continue autonomous Telegram Desktop development from the shared ai-tdesktop repository. Use when the user invokes $continue or /continue, asks Codex to keep working through the AI queue, or wants one command to process the local inbox, resume active or previously blocked unfinished work, consume this checkout's claimed queue, and claim new work until nothing eligible remains. --- # Continue AI Work @@ -37,7 +37,9 @@ work. - `todo` plus `claimed_by: null` is shared unreserved work; - `todo` plus this `checkout_tag` is this checkout's reserved queue; - `in-progress` plus this tag is the one active task; -- `approved` and `blocked` are terminal; +- `blocked` plus this tag is paused unfinished work that the next invocation + retries once; +- `approved` is the only completed terminal state; - work claimed by another checkout is invisible to this scheduler. Do not infer a claim from who processed an inbox receipt. Do not steal or @@ -64,13 +66,16 @@ one `claimed_at` value and ascending `claim_order`. Dependencies do not prevent reservation, but they do prevent a task from starting. Scope hints filter new claims only. Always resume this checkout's existing -`in-progress` task and then its already claimed queue before taking more shared -work, unless the user explicitly asks to stop or reassign them. +`in-progress` task, previously blocked work, and already claimed queue before +taking more shared work, unless the user explicitly asks to stop or reassign +them. ## Main loop -Repeat these steps. Refresh queue JSON after every delegated operation and -state transition; do not rely on a stale snapshot. +Create an empty invocation-local `attempted_blocked` set, then repeat these +steps. Refresh queue JSON after every delegated operation and state transition; +do not rely on a stale snapshot. The set is scheduler memory only and is never +written to task state. ### 1. Process the inbox @@ -92,7 +97,31 @@ leave the input or active transaction recoverable. If this checkout has an `in-progress` task, select it. There must be at most one. Spawn one stateful performer as described below. -### 3. Start reserved work +### 3. Retry paused unfinished work + +Otherwise select the first ready task in this checkout's `own_blocked` queue +whose id is not in `attempted_blocked`. Readiness means every `depends_on` task +is `approved`. Add its id to `attempted_blocked` before changing state, then +transition it atomically: + +```bash +python3 .agents/skills/process-inbox/scripts/workspace.py retry \ + --task +``` + +The retry preserves the claim and all source, work, test, result, and evidence +artifacts, changes the task back to `in-progress`, and resumes from the first +incomplete validated boundary. It clears only `work/discovered-routed.md`, when +present, so discoveries in the next result are deduplicated and routed again. +Never discard or redo completed work merely because the prior run ended +`blocked`. + +If a performer ends `blocked` during this invocation, add that task id to +`attempted_blocked`. Do not retry it again in the same invocation; continue +with other independent work. A later `$continue` or `/continue` starts with a +fresh set and tries it again. + +### 4. Start reserved work Otherwise select the first ready task in this checkout's claimed `todo` queue. Readiness means every `depends_on` task is `approved`. Transition it atomically: @@ -105,7 +134,7 @@ python3 .agents/skills/process-inbox/scripts/workspace.py start \ Then spawn its performer. Leave claimed tasks with unfinished prerequisites as `todo` and consider later ready tasks. -### 4. Claim shared work +### 5. Claim shared work Otherwise inspect unclaimed `todo` work matching the scope. For a plain invocation select only the first ready task. For an explicit batch reservation, @@ -122,18 +151,21 @@ The claim is committed and published before source work starts. Refresh the queue; then return to step 3. A publish race may mean another checkout won the task. Do not resolve that by overwriting shared state; refresh and choose again. -### 5. Stop normally +### 6. Stop normally Stop when the inbox is empty and none of these exist: - this checkout's active task; +- a ready task in this checkout's blocked queue that is not in + `attempted_blocked`; - a ready task in this checkout's claimed queue; - a ready unclaimed task for a plain run, or any unclaimed task matching an explicit batch-reservation scope. -Claimed tasks belonging to other checkouts do not keep this run alive. -Claimed tasks waiting on prerequisites remain visible in the final summary but -do not cause a busy loop. +Blocked or claimed tasks belonging to other checkouts do not keep this run +alive. Claimed tasks waiting on prerequisites and blocked tasks already tried +in this invocation remain visible in the final summary but do not cause a busy +loop. ## Spawn one performer @@ -146,24 +178,29 @@ Source checkout: AI slot worktree: Checkout tag: Task: -Own this task until it reaches approved or blocked. You may use the bounded -leaf delegation required by the skill. Do not select or claim another task. +Own this task until this attempt reaches approved or blocked. You may use the +bounded leaf delegation required by the skill. Do not select or claim another +task. ``` The performer is stateful. Never duplicate it. Poll at no more than 60-second intervals, distinguish progress from completion using its task artifacts, and -send a follow-up to the same target if it becomes idle without a terminal state. +send a follow-up to the same target if it becomes idle without an +attempt-boundary state. After it returns, require: -- source checkout clean and at the performer's retained commit; -- task `state.yaml` terminal and published to AI master; or +- source checkout clean at the performer's recorded run tip, with its retained + implementation commit in current history when one exists; +- task `state.yaml` at an attempt boundary (`approved` or `blocked`) and + published to AI master; or - a clearly reported global hard stop that makes further work unsafe. -A clean terminal `blocked` task does not stop the scheduler; continue with -independent work. A dirty checkout, file-lock build failure, missing test -account, unresolved AI publication conflict, or other global environment -failure stops the loop. +A clean `blocked` attempt does not complete the task and does not stop the +scheduler. Add it to `attempted_blocked` and continue with independent work; +the next invocation retries it before reserved or shared work. A dirty +checkout, file-lock build failure, missing test account, unresolved AI +publication conflict, or other global environment failure stops the loop. The missing `test_TelegramForcePortable` golden account is the only portable-folder state that is a global stop. Live and real portable folders @@ -173,7 +210,8 @@ must never stop `/continue`. ## Route discovered follow-ups -After every terminal performer, read its published `work/result.md`. If it says +After every performer attempt ending `approved` or `blocked`, read its published +`work/result.md`. If it says `Discovered: present` and has no `work/discovered-routed.md`, route the complete blocks under `## Discovered tasks` before selecting more shared work. @@ -204,9 +242,10 @@ Refresh queue JSON after routing, then resume the main loop. ## Report Return one compact run summary: inbox receipt if processed, tasks approved, -tasks blocked with exact unverified behavior, tasks newly claimed or left -queued, routed discoveries, elapsed time, and why the loop stopped. Make any -global hard stop, retained unsafe state, or incomplete verification visually -unmistakable. The human should not need to invoke another command merely to -advance to the next eligible task. Never include source or AI commit hashes; -task ids are the only durable locators. +unfinished blocked tasks with exact unverified behavior and whether they were +retried in this invocation, tasks newly claimed or left queued, routed +discoveries, elapsed time, and why the loop stopped. Make any global hard stop, +retained unsafe state, or incomplete verification visually unmistakable. The +human should not need to invoke another command merely to advance to the next +eligible task. Never include source or AI commit hashes; task ids are the only +durable locators. diff --git a/.agents/skills/continue/agents/openai.yaml b/.agents/skills/continue/agents/openai.yaml index 7c74bb1aaa..7c0cdef96a 100644 --- a/.agents/skills/continue/agents/openai.yaml +++ b/.agents/skills/continue/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Continue AI Work" - short_description: "Drain the shared Telegram AI work queue" - default_prompt: "Use $continue to process the inbox and continue queued Telegram Desktop work." + short_description: "Resume unfinished work and drain the AI queue" + default_prompt: "Use $continue to process the inbox, retry paused unfinished tasks, and continue queued Telegram Desktop work." diff --git a/.agents/skills/perform-task/SKILL.md b/.agents/skills/perform-task/SKILL.md index b791dee639..152aa06796 100644 --- a/.agents/skills/perform-task/SKILL.md +++ b/.agents/skills/perform-task/SKILL.md @@ -1,13 +1,14 @@ --- name: perform-task -description: Resolve, claim, implement, commit, and verify exactly one existing ai-tdesktop task by short slug or full dated id. Use when the user invokes $perform-task or /perform-task with a known task name, or when the continue scheduler delegates one selected task. Runs the complete mature context, planning, assessment, Debug build, review, test-loop, Computer Use, resume, and publication pipeline without selecting any additional work. +description: Resolve, claim, resume, implement, commit, and verify exactly one existing ai-tdesktop task by short slug or full dated id, including previously blocked unfinished work. Use when the user invokes $perform-task or /perform-task with a known task name, or when the continue scheduler delegates one selected task. Runs the complete mature context, planning, assessment, Debug build, review, test-loop, Computer Use, resume, and publication pipeline without selecting any additional work. --- # Perform One AI Task -Own exactly one task through a Telegram commit and terminal AI commit. Do not +Own exactly one task through a Telegram commit and a published attempt-boundary +AI commit. Do not process the inbox, split the task, drain the queue, or select a follow-up after -this task reaches a terminal state. +this attempt reaches `approved` or `blocked`. ## Read the complete engine @@ -66,7 +67,7 @@ owner before source work. - If another task is already `in-progress` for this checkout, stop. Never abandon or supersede it implicitly. -- If this task is `approved` or `blocked`, report its terminal result and stop. +- If this task is `approved`, report its completed result and stop. - If it is claimed by another checkout, stop without touching it. - If it is unclaimed but its dependencies are unfinished, report those dependencies and stop without claiming it. @@ -84,8 +85,20 @@ owner before source work. --task ``` +- If it is `blocked` and owned by this checkout, retry it: + + ```bash + python3 .agents/skills/process-inbox/scripts/workspace.py retry \ + --task + ``` + + Preserve its claim, implementation, plans, reviews, tests, result, and + evidence. Treat the prior blocked result as the exact resumption handoff and + continue from the first incomplete validated boundary rather than starting + over. + - If it is already `in-progress` and owned by this checkout, resume it without - another claim or start commit. + another claim, start, or retry commit. Refresh with `resolve` after every state mutation. The source pipeline starts only after canonical AI state shows this task `in-progress` for this checkout. @@ -97,15 +110,16 @@ Execute `references/pipeline.md` exactly. The task must normally produce: 1. one or more tested Telegram implementation-attempt commits, each with an exact one-line subject, blank line, and `Task: `; 2. tracked resumable AI checkpoints during phase work; -3. a terminal AI slot commit containing final result/state, rebased and +3. an attempt-boundary AI slot commit containing final result/state, rebased and published to canonical AI master without force. -Do not report success from a source commit alone. The final AI commit must also -be canonical. On a retryable concurrent-master race, keep fetching, rebasing, -and publishing until it succeeds. On a semantic conflict, unsafe checkout, or -unreachable remote, preserve resumable state and report a hard stop. +Do not report success from a source commit alone. The attempt-boundary AI +commit must also be canonical. On a retryable concurrent-master race, keep +fetching, rebasing, and publishing until it succeeds. On a semantic conflict, +unsafe checkout, or unreachable remote, preserve resumable state and report a +hard stop. -Return a compact result with the full task id, terminal status or hard stop, +Return a compact result with the full task id, attempt status or hard stop, attempts, touched files, canonical-publication confirmation, and exact evidence or unverified behavior. Never persist or report commit hashes; the full task id is the only cross-repository link. diff --git a/.agents/skills/perform-task/agents/openai.yaml b/.agents/skills/perform-task/agents/openai.yaml index e0a4d84b7a..814b685b92 100644 --- a/.agents/skills/perform-task/agents/openai.yaml +++ b/.agents/skills/perform-task/agents/openai.yaml @@ -1,4 +1,4 @@ interface: display_name: "Perform AI Task" - short_description: "Implement one Telegram AI task by name" - default_prompt: "Use $perform-task to resolve, implement, verify, and publish one named ai-tdesktop task." + short_description: "Implement or resume one Telegram AI task" + default_prompt: "Use $perform-task to resolve, resume when blocked, implement, verify, and publish one named ai-tdesktop task." diff --git a/.agents/skills/perform-task/references/pipeline.md b/.agents/skills/perform-task/references/pipeline.md index 0717e1110c..75f4fdf946 100644 --- a/.agents/skills/perform-task/references/pipeline.md +++ b/.agents/skills/perform-task/references/pipeline.md @@ -9,7 +9,7 @@ - [Implementation phases](#implementation-phases) - [Telegram commits](#telegram-commits) - [Test loop adapter](#test-loop-adapter) -- [Terminal state](#terminal-state) +- [Attempt-boundary state](#attempt-boundary-state) - [Failure handling](#failure-handling) ## Contract and inputs @@ -41,6 +41,7 @@ PROJECT_FILE = AI_SLOT/projects//project.md, or none PREVIOUS_CONTEXT = latest approved project task's work/context.md, or none BASE_REF = refs/ai-tasks/TASK_ID/base GREEN_REF = refs/ai-tasks/TASK_ID/green +RUN_REF = refs/ai-tasks/TASK_ID/run ``` Capture a wall-clock start time for the final elapsed-time report. @@ -89,16 +90,18 @@ Before planning or editing: source-begin --source-root SOURCE_ROOT --task TASK_ID ``` - On resume, run the same command. It verifies the local refs and reconciles - them from contiguous tip commits with this task's exact `Task:` line after a - source rebase. Never resolve or record a ref's object name in an artifact. + On resume, run the same command. It verifies the local refs, rediscovers the + latest exact `Task:` commit from current first-parent history when needed, + and records current `HEAD` in `RUN_REF`. Later task commits may remain above + the retained implementation. Never resolve or record a ref's object name in + an artifact. 6. For an interrupted run, allow dirty Telegram paths only when every one is listed in `work/owned-paths.txt` and completed phase artifacts prove this task owns them. Otherwise hard-stop without cleaning them. Do not stash. Do not reset, restore, stage, commit, or delete an unexpected path. Invocation authorizes recovery only for paths proven to belong to this -task and only back to `GREEN_REF` or `BASE_REF`, as appropriate. +task and only back to `RUN_REF` or `BASE_REF`, as appropriate. ## Artifacts and resumption @@ -248,10 +251,12 @@ attribution, or any other trailer. Track rationale in the AI task. If a short durable explanation will help source-history readers, write `SOURCE_ROOT/tasks/TASK_ID.md` and include it in the same commit. -Record only the attempt number. Use `BASE_REF` as the old behavioral baseline -across all attempts and `GREEN_REF` as the current retained implementation. -These refs are local recovery mechanics: never copy their resolved object names -into AI artifacts, source notes, reports, chat, or commit messages. +Record only the attempt number. Use `BASE_REF` as the original local baseline, +`GREEN_REF` as the latest retained exact task commit, and `RUN_REF` as the +current clean source tip on which this resumed run operates. Later tasks may +make `GREEN_REF` an ancestor of `RUN_REF`. These refs are local recovery +mechanics: never copy their resolved object names into AI artifacts, source +notes, reports, chat, or commit messages. ## Test loop adapter @@ -266,7 +271,9 @@ rules, with these external-task safety adaptations: in `work/test-overlay.paths`; never introduce an untracked source file. - Save the overlay with `git diff --binary HEAD > work/test-overlay.patch`, verify it is nonempty and reapplicable, then restore only inventoried overlay - paths to `GREEN_REF`. Do not run a repository-wide hard reset. + paths to `RUN_REF`. Do not run a repository-wide hard reset. After an + implementation-fix commit, move both `GREEN_REF` and `RUN_REF` to the new + clean tip before reapplying the overlay. - Reapply with `git apply --3way`; re-author a conflicting hunk from `test.md` rather than leaving conflict markers. - Missing `test_TelegramForcePortable` is the only portable-account setup @@ -290,26 +297,31 @@ rules, with these external-task safety adaptations: - Delete the overlay-bearing Debug executable on every terminal test exit so the user cannot launch it accidentally. -The test author must read both the full task specification and the complete -`BASE_REF..GREEN_REF` diff. It writes checks before running, covers every -acceptance surface, declares a falsifiable oracle for each, and never reuses a -generic navigate-and-screenshot scenario. Missing or ambiguous evidence is -`TEST_FLAW`; no expected delta from `BASE_REF` is `IMPL_BUG`. Two identical -consecutive failure signatures block early. A known implementation bug at the -attempt cap is implementation-blocked, not a successful retained commit. +The test author must read the full task specification and every current-branch +commit whose message has this task's exact `Task:` line. For an uninterrupted +contiguous run this is the `BASE_REF..GREEN_REF` diff; for a resumed older task, +combine the exact task commits and inspect their current code at `RUN_REF` +without treating intervening tasks as this task's changes. It writes checks +before running, covers every acceptance surface, declares a falsifiable oracle +for each, and never reuses a generic navigate-and-screenshot scenario. Missing +or ambiguous evidence is `TEST_FLAW`; no expected task delta is `IMPL_BUG`. Two +identical consecutive failure signatures block early. A known implementation +bug at the attempt cap is implementation-blocked, not a successful retained +commit. Skip runtime testing only for a task with no runnable behavior. Record `NOT_APPLICABLE` and exact file-level validation. Configuration alone is not a reason to skip. -## Terminal state +## Attempt-boundary state -Before terminal publication, require a clean Telegram checkout at `GREEN_REF` -when an implementation is retained, no overlay in source, no owned live test -copy, and no overlay-bearing executable. For implementation-blocked work with -no retained commit, restore only proven owned paths to `BASE_REF`. For -test-blocked work retain the latest implementation commit and state the exact -unverified behavior. +Before publishing an approved or blocked attempt, require a clean Telegram +checkout at `RUN_REF`, with `GREEN_REF` in its history when an implementation +is retained, no overlay in source, no owned live test copy, and no +overlay-bearing executable. For implementation-blocked work with no retained +commit, restore only proven owned paths to `BASE_REF`. For test-blocked work +retain the latest implementation commit and state the exact unverified +behavior. Write `work/result.md` with exactly one value for every field: @@ -336,7 +348,8 @@ For approved project work, promote `work/project.proposed.md` to the project's `project.md` immediately before final AI publication. For blocked work, retain the proposal only as a task artifact. -Publish terminal AI state only after the Telegram commit and result are final: +Publish the attempt-boundary AI state only after the Telegram commit and result +are final: ```bash python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \ @@ -345,12 +358,14 @@ python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \ ``` The helper verifies a clean source checkout, local task refs, current `HEAD`, -and the exact three-line commit message. It commits the task result and state in -the AI slot, fetches newer canonical state when configured, rebases the slot, -publishes without force, fast-forwards local AI master, and then deletes the -local task refs. Do not report a terminal task until that final AI commit -reaches canonical master. Preserve an unpublished slot commit on a semantic -conflict or remote outage and hard-stop instead of pretending completion. +and the retained implementation's exact three-line commit message. It commits +the task result and state in the AI slot, fetches newer canonical state when +configured, rebases the slot, publishes without force, and fast-forwards local +AI master. It deletes all local task refs after approval; after a block it +deletes only `RUN_REF` and retains implementation recovery refs for the next +invocation. Do not report an attempt boundary until that AI commit reaches +canonical master. Preserve an unpublished slot commit on a semantic conflict +or remote outage and hard-stop instead of pretending completion. When `Discovered: present`, preserve complete task blocks in `result.md`. The `continue` scheduler must route them through the same independent-testability @@ -359,13 +374,16 @@ planner into new unclaimed dated tasks before selecting more shared work. ## Failure handling - A disposable phase may be retried once through the wait ladder. Never fresh - retry the performer. -- A clean terminal `blocked` task lets `continue` proceed with independent - work. A dirty/non-buildable checkout or global environment problem stops it. + retry the performer within the same attempt; a later `continue` invocation + creates one new performer to resume a published blocked task. +- A clean `blocked` attempt leaves the task unfinished. It lets `continue` + proceed with independent work, but the next invocation retries it once before + reserved or shared work. A dirty/non-buildable checkout or global environment + problem stops the current invocation. - A file-lock build error always stops immediately and asks the human to close this checkout's Telegram/debugger. - Missing optional screenshots or mockups never block. - Never silently pass unverified behavior. Surface every blocked or partially verified task with exact `work/test.md`, `work/result.md`, and evidence paths. -- In Goal mode, report terminal blocked state without claiming achievement; +- In Goal mode, report blocked state without claiming achievement; complete the goal only when every selected task is approved. diff --git a/.agents/skills/process-inbox/scripts/workspace.py b/.agents/skills/process-inbox/scripts/workspace.py index f105bc16d9..1021326b37 100755 --- a/.agents/skills/process-inbox/scripts/workspace.py +++ b/.agents/skills/process-inbox/scripts/workspace.py @@ -514,7 +514,7 @@ def resolve_task(states, value): ] unfinished = [ task for task in exact - if task["status"] in ("todo", "in-progress") + if task["status"] in ("todo", "in-progress", "blocked") ] candidates = unfinished or exact if not candidates: @@ -541,15 +541,31 @@ def command_queue(args): tag = config["checkout_tag"] values = [task_summary(task, states) for task in states.values()] own_in_progress = sorted( - (task for task in values if task["claimed_by"] == tag and task["status"] == "in-progress"), + ( + task for task in values + if task["claimed_by"] == tag and task["status"] == "in-progress" + ), key=queue_sort_key, ) own_todo = sorted( - (task for task in values if task["claimed_by"] == tag and task["status"] == "todo"), + ( + task for task in values + if task["claimed_by"] == tag and task["status"] == "todo" + ), + key=queue_sort_key, + ) + own_blocked = sorted( + ( + task for task in values + if task["claimed_by"] == tag and task["status"] == "blocked" + ), key=queue_sort_key, ) unclaimed_todo = sorted( - (task for task in values if task["claimed_by"] is None and task["status"] == "todo"), + ( + task for task in values + if task["claimed_by"] is None and task["status"] == "todo" + ), key=lambda task: (task["created"], task["id"]), ) violations = [] @@ -558,17 +574,23 @@ def command_queue(args): for task in values: if task["status"] == "in-progress" and task["claimed_by"] is None: violations.append(f"{task['id']} is in-progress but unclaimed") + if task["status"] == "blocked" and task["claimed_by"] is None: + violations.append(f"{task['id']} is blocked but unclaimed") inbox_file = main / "inbox" / "inbox.md" result = { **config, - "inbox_nonempty": inbox_file.is_file() and bool(inbox_file.read_text(encoding="utf-8").strip()), + "inbox_nonempty": ( + inbox_file.is_file() + and bool(inbox_file.read_text(encoding="utf-8").strip()) + ), "own_in_progress": own_in_progress, "own_todo": own_todo, + "own_blocked": own_blocked, "unclaimed_todo": unclaimed_todo, "other_claimed_unfinished": sum( 1 for task in values if task["claimed_by"] not in (None, tag) - and task["status"] in ("todo", "in-progress") + and task["status"] in ("todo", "in-progress", "blocked") ), "ai_main_dirty": main_dirty, "slot_dirty": slot_dirty, @@ -699,6 +721,53 @@ def command_start(args): }, indent=2, sort_keys=True)) +def command_retry(args): + config = worktree_config(args, create=True) + sync_canonical(config) + slot = Path(config["slot_worktree"]) + states = load_states(slot) + task = states.get(args.task) + if task is None: + raise WorkspaceError(f"Task does not exist: {args.task}") + if ( + task["status"] != "blocked" + or task["claimed_by"] != config["checkout_tag"] + ): + raise WorkspaceError( + f"Task is not blocked work owned by this checkout: {args.task}" + ) + if not task_ready(task, states): + raise WorkspaceError(f"Task has unfinished dependencies: {args.task}") + active = [ + value["id"] for value in states.values() + if value["claimed_by"] == config["checkout_tag"] + and value["status"] == "in-progress" + ] + if active: + raise WorkspaceError("Another task is already in progress: " + ", ".join(active)) + path = state_path(slot, args.task) + update_state(path, { + "status": "in-progress", + "phase": "resume", + "lease_until": None, + }) + paths = [str(path.relative_to(slot))] + routed = path.parent / "work" / "discovered-routed.md" + if routed.is_file(): + routed.unlink() + paths.append(str(routed.relative_to(slot))) + commit = commit_paths( + config, + paths, + f"Resume {args.task} on {config['checkout_tag']}", + ) + print(json.dumps({ + "task": args.task, + "status": "in-progress", + "published": bool(commit), + }, indent=2, sort_keys=True)) + + def task_action_config(args, require_status="in-progress", allow_project=False): config = worktree_config(args, create=True) slot = Path(config["slot_worktree"]) @@ -742,34 +811,61 @@ def task_commit_matches(source, value, task_id): ) -def validate_current_task_commit(source, task_id): - if not task_commit_matches(source, "HEAD", task_id): +def validate_task_commit(source, value, task_id): + if not task_commit_matches(source, value, task_id): raise WorkspaceError( "Telegram commit message must be exactly a one-line subject, a blank line, " f"and Task: {task_id}" ) -def task_series_base(source, task_id): - current = "HEAD" - if not task_commit_matches(source, current, task_id): - return None - while True: - parent = resolved_ref(source, f"{current}^") - if parent is None: - raise WorkspaceError("A task implementation cannot start at the repository root") - if not task_commit_matches(source, parent, task_id): - return parent - current = parent +def task_series_refs(source, task_id): + commits = run_git( + source, + "log", + "--first-parent", + "--format=%H", + "--fixed-strings", + f"--grep=Task: {task_id}", + "HEAD", + ).stdout.splitlines() + for green in commits: + if not task_commit_matches(source, green, task_id): + continue + current = green + while True: + parent = resolved_ref(source, f"{current}^") + if parent is None: + raise WorkspaceError( + "A task implementation cannot start at the repository root" + ) + if not task_commit_matches(source, parent, task_id): + return parent, green + current = parent + return None + + +def is_ancestor(source, older, newer="HEAD"): + return not run_git( + source, + "merge-base", + "--is-ancestor", + older, + newer, + check=False, + ).returncode def validate_source_state(config, task_id, required): source = Path(config["source_root"]) base = resolved_ref(source, source_task_ref(task_id, "base")) green = resolved_ref(source, source_task_ref(task_id, "green")) + run = resolved_ref(source, source_task_ref(task_id, "run")) head = resolved_ref(source, "HEAD") if base is None: raise WorkspaceError("The local task baseline ref is missing") + if run is None or head != run: + raise WorkspaceError("Telegram HEAD no longer matches the task run ref") if green is None: if required: raise WorkspaceError("An approved task must retain a Telegram implementation commit") @@ -778,9 +874,11 @@ def validate_source_state(config, task_id, required): "A blocked task without an implementation must be restored to its local baseline" ) return - if head != green: - raise WorkspaceError("Telegram HEAD is not the locally retained task implementation") - validate_current_task_commit(source, task_id) + if not is_ancestor(source, green, head): + raise WorkspaceError( + "The retained task implementation is not in Telegram HEAD history" + ) + validate_task_commit(source, green, task_id) def ensure_no_persisted_commit_hashes(root): @@ -799,9 +897,10 @@ def ensure_no_persisted_commit_hashes(root): raise WorkspaceError(f"Persisted commit hash found in {path}") -def delete_source_refs(config, task_id): +def delete_source_refs(config, task_id, retain_implementation=False): source = Path(config["source_root"]) - for name in ("green", "base"): + names = ("run",) if retain_implementation else ("run", "green", "base") + for name in names: value = source_task_ref(task_id, name) if resolved_ref(source, value) is not None: run_git(source, "update-ref", "-d", value) @@ -812,28 +911,33 @@ def command_source_begin(args): source = Path(config["source_root"]) base = source_task_ref(args.task, "base") green = source_task_ref(args.task, "green") + run = source_task_ref(args.task, "run") base_value = resolved_ref(source, base) green_value = resolved_ref(source, green) head = resolved_ref(source, "HEAD") - series_base = task_series_base(source, args.task) - if series_base is not None: - run_git(source, "update-ref", base, series_base) - run_git(source, "update-ref", green, "HEAD") - state = "reconciled" if base_value is not None else "recovered" - elif base_value is None: - ensure_clean(source, "Telegram source checkout") - run_git(source, "update-ref", base, "HEAD") - state = "initialized" - elif green_value is None and head != base_value: - ensure_clean(source, "Telegram source checkout") - run_git(source, "update-ref", base, "HEAD") - state = "reconciled" - elif green_value is not None and head != green_value: - raise WorkspaceError( - "Telegram HEAD no longer matches this task's retained implementation" - ) - else: + retained = ( + base_value is not None + and green_value is not None + and is_ancestor(source, base_value, green_value) + and is_ancestor(source, green_value, head) + and task_commit_matches(source, green_value, args.task) + ) + if retained or (base_value is not None and green_value is None and head == base_value): state = "resumed" + else: + series = task_series_refs(source, args.task) + if series is not None: + series_base, series_green = series + run_git(source, "update-ref", base, series_base) + run_git(source, "update-ref", green, series_green) + state = "reconciled" if base_value is not None else "recovered" + else: + ensure_clean(source, "Telegram source checkout") + run_git(source, "update-ref", base, "HEAD") + if green_value is not None: + run_git(source, "update-ref", "-d", green) + state = "reconciled" if base_value is not None else "initialized" + run_git(source, "update-ref", run, "HEAD") print(json.dumps({ "task": args.task, "source_state": state, @@ -852,8 +956,9 @@ def command_source_mark_green(args): raise WorkspaceError("The local task baseline ref is missing") if run_git(source, "merge-base", "--is-ancestor", base, "HEAD", check=False).returncode: raise WorkspaceError("The retained implementation does not descend from the task baseline") - validate_current_task_commit(source, args.task) + validate_task_commit(source, "HEAD", args.task) run_git(source, "update-ref", source_task_ref(args.task, "green"), "HEAD") + run_git(source, "update-ref", source_task_ref(args.task, "run"), "HEAD") print(json.dumps({ "task": args.task, "source_state": "retained", @@ -901,7 +1006,11 @@ def command_finish(args): paths, f"{verb} {args.task}", ) - delete_source_refs(config, args.task) + delete_source_refs( + config, + args.task, + retain_implementation=(args.status == "blocked"), + ) print(json.dumps({ "task": args.task, "status": args.status, @@ -1148,6 +1257,11 @@ def parse_args(): start.add_argument("--task", required=True) start.set_defaults(handler=command_start) + retry = subparsers.add_parser("retry") + add_common_arguments(retry) + retry.add_argument("--task", required=True) + retry.set_defaults(handler=command_retry) + checkpoint = subparsers.add_parser("checkpoint") add_common_arguments(checkpoint) checkpoint.add_argument("--task", required=True) diff --git a/.agents/skills/process-inbox/scripts/workspace_test.py b/.agents/skills/process-inbox/scripts/workspace_test.py new file mode 100644 index 0000000000..9a00d3b6ec --- /dev/null +++ b/.agents/skills/process-inbox/scripts/workspace_test.py @@ -0,0 +1,218 @@ +#!/usr/bin/env python3 + +import contextlib +import io +from pathlib import Path +import subprocess +import tempfile +from types import SimpleNamespace +import unittest +from unittest import mock + +import workspace + + +TASK_ID = "2026/07/19/correct-recent-search-peer-actions" + + +def task_state(status, claimed_by="macbook-twork"): + return { + "id": TASK_ID, + "title": "Correct recent-search peer actions", + "status": status, + "created": "2026-07-19", + "project": None, + "depends_on": [], + "claimed_by": claimed_by, + "claimed_at": "2026-07-19T14:28:01+04:00", + "claim_order": 1, + "lease_until": None, + "phase": "blocked" if status == "blocked" else None, + } + + +def write_task(slot, status="blocked"): + directory = slot / "tasks" / TASK_ID + (directory / "work").mkdir(parents=True) + (directory / "task.md").write_text( + "# Correct recent-search peer actions\n", + encoding="utf-8", + ) + (directory / "state.yaml").write_text( + f"""status: {status} +created: 2026-07-19 +project: null +depends_on: [] +claimed_by: macbook-twork +claimed_at: 2026-07-19T14:28:01+04:00 +claim_order: 1 +lease_until: null +phase: {status} +inbox_receipt: receipts/2026/07/19/test.md +""", + encoding="utf-8", + ) + return directory + + +def git(repo, *args): + return subprocess.run( + ["git", "-C", str(repo), *args], + check=True, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + ).stdout.strip() + + +class WorkspaceTest(unittest.TestCase): + def test_resolve_prefers_blocked_over_approved_history(self): + blocked = task_state("blocked") + approved = { + **task_state("approved"), + "id": "2026/07/18/correct-recent-search-peer-actions", + } + states = {task["id"]: task for task in (approved, blocked)} + + resolved = workspace.resolve_task(states, "correct-recent-search-peer-actions") + + self.assertEqual(resolved["id"], TASK_ID) + + def test_retry_reopens_owned_blocked_task_and_resets_routing_marker(self): + with tempfile.TemporaryDirectory() as temporary: + slot = Path(temporary) + directory = write_task(slot) + routed = directory / "work" / "discovered-routed.md" + routed.write_text("routed\n", encoding="utf-8") + config = { + "checkout_tag": "macbook-twork", + "slot_worktree": str(slot), + } + with ( + mock.patch.object(workspace, "worktree_config", return_value=config), + mock.patch.object(workspace, "sync_canonical"), + mock.patch.object(workspace, "commit_paths", return_value=True) as commit, + contextlib.redirect_stdout(io.StringIO()), + ): + workspace.command_retry(SimpleNamespace(task=TASK_ID)) + + state = workspace.load_state(slot, directory / "state.yaml") + self.assertEqual(state["status"], "in-progress") + self.assertEqual(state["phase"], "resume") + self.assertFalse(routed.exists()) + paths = commit.call_args.args[1] + self.assertIn(f"tasks/{TASK_ID}/state.yaml", paths) + self.assertIn(f"tasks/{TASK_ID}/work/discovered-routed.md", paths) + + def test_retry_refuses_to_compete_with_active_task(self): + with tempfile.TemporaryDirectory() as temporary: + slot = Path(temporary) + write_task(slot) + active = slot / "tasks" / "2026/07/19/active-task" + active.mkdir(parents=True) + (active / "task.md").write_text("# Active task\n", encoding="utf-8") + (active / "state.yaml").write_text( + """status: in-progress +created: 2026-07-19 +project: null +depends_on: [] +claimed_by: macbook-twork +claimed_at: 2026-07-19T15:00:00+04:00 +claim_order: 1 +lease_until: null +phase: setup +inbox_receipt: receipts/2026/07/19/test.md +""", + encoding="utf-8", + ) + config = { + "checkout_tag": "macbook-twork", + "slot_worktree": str(slot), + } + with ( + mock.patch.object(workspace, "worktree_config", return_value=config), + mock.patch.object(workspace, "sync_canonical"), + ): + with self.assertRaisesRegex(workspace.WorkspaceError, "already in progress"): + workspace.command_retry(SimpleNamespace(task=TASK_ID)) + + def test_retained_task_commit_can_precede_later_work(self): + with tempfile.TemporaryDirectory() as temporary: + repo = Path(temporary) + git(repo, "init") + git(repo, "config", "user.name", "Workflow Test") + git(repo, "config", "user.email", "workflow@example.invalid") + tracked = repo / "tracked.txt" + tracked.write_text("base\n", encoding="utf-8") + git(repo, "add", "tracked.txt") + git(repo, "commit", "-m", "Create baseline") + base = git(repo, "rev-parse", "HEAD") + tracked.write_text("task\n", encoding="utf-8") + git(repo, "commit", "-am", "Correct peer actions", "-m", f"Task: {TASK_ID}") + green = git(repo, "rev-parse", "HEAD") + tracked.write_text("later\n", encoding="utf-8") + git(repo, "commit", "-am", "Add later independent work") + + self.assertEqual(workspace.task_series_refs(repo, TASK_ID), (base, green)) + config = {"source_root": str(repo)} + with ( + mock.patch.object(workspace, "task_action_config", return_value=(config, None)), + contextlib.redirect_stdout(io.StringIO()), + ): + workspace.command_source_begin(SimpleNamespace(task=TASK_ID)) + self.assertEqual( + workspace.resolved_ref(repo, workspace.source_task_ref(TASK_ID, "base")), + base, + ) + self.assertEqual( + workspace.resolved_ref(repo, workspace.source_task_ref(TASK_ID, "green")), + green, + ) + self.assertEqual( + workspace.resolved_ref(repo, workspace.source_task_ref(TASK_ID, "run")), + git(repo, "rev-parse", "HEAD"), + ) + workspace.validate_source_state(config, TASK_ID, True) + workspace.delete_source_refs(config, TASK_ID, retain_implementation=True) + self.assertIsNotNone( + workspace.resolved_ref(repo, workspace.source_task_ref(TASK_ID, "base")) + ) + self.assertIsNotNone( + workspace.resolved_ref(repo, workspace.source_task_ref(TASK_ID, "green")) + ) + self.assertIsNone( + workspace.resolved_ref(repo, workspace.source_task_ref(TASK_ID, "run")) + ) + + def test_source_begin_initializes_new_task_without_a_green_ref(self): + with tempfile.TemporaryDirectory() as temporary: + repo = Path(temporary) + git(repo, "init") + git(repo, "config", "user.name", "Workflow Test") + git(repo, "config", "user.email", "workflow@example.invalid") + (repo / "tracked.txt").write_text("base\n", encoding="utf-8") + git(repo, "add", "tracked.txt") + git(repo, "commit", "-m", "Create baseline") + config = {"source_root": str(repo)} + with ( + mock.patch.object(workspace, "task_action_config", return_value=(config, None)), + contextlib.redirect_stdout(io.StringIO()), + ): + workspace.command_source_begin(SimpleNamespace(task=TASK_ID)) + + head = git(repo, "rev-parse", "HEAD") + self.assertEqual( + workspace.resolved_ref(repo, workspace.source_task_ref(TASK_ID, "base")), + head, + ) + self.assertEqual( + workspace.resolved_ref(repo, workspace.source_task_ref(TASK_ID, "run")), + head, + ) + self.assertIsNone( + workspace.resolved_ref(repo, workspace.source_task_ref(TASK_ID, "green")) + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/.claude/ai-workflow-adapter.md b/.claude/ai-workflow-adapter.md new file mode 100644 index 0000000000..d7bed9f934 --- /dev/null +++ b/.claude/ai-workflow-adapter.md @@ -0,0 +1,55 @@ +# Claude Code AI Workflow Adapter + +Apply this adapter only when a command explicitly loads it. The shared +`.agents/skills/` workflow remains authoritative for task selection, artifacts, +source changes, builds, testing, commits, resumability, and AI publication. +This file adapts harness mechanics and removes unnecessary text normalization. + +## Delegation + +- Use Claude Code's `Agent` tool wherever the shared workflow says to spawn a + subagent, worker, performer, planner, or leaf. +- Treat `fork_turns: "none"` as a fresh Agent invocation with a self-contained + prompt containing exact repository, task, artifact, and input paths. Do not + rely on the parent conversation being inherited. +- Tell every leaf Agent not to delegate and never to commit. Preserve the + shared workflow's single-writer and one-stateful-performer constraints. +- A foreground Agent call may replace Codex-specific polling. Treat its short + reply as notification and validate the required files and repository state. + When Claude exposes a resumable agent id and more work is needed from that + same stateful worker, resume that id; never create a duplicate performer or + duplicate an agent whose writes may still be in flight. +- Translate Codex-specific wait, message, follow-up, list, and interrupt calls + to the closest available Agent operation. Preserve the artifact heartbeat, + stall windows, one-retry limit, and terminal-state rules. Never launch a + nested `claude` process from Bash. +- If the first real leaf Agent is rejected before work begins because nested + delegation is unavailable, use the shared same-session fallback. Do not + treat mere presence of the Agent tool as a successful delegation probe. +- Whenever an Agent is asked to run `process-inbox`, `perform-task`, a phase + prompt, or discovered-task routing, explicitly tell it to read this adapter + completely before the applicable shared skill or reference. + +## Text handling + +Do not run a dedicated line-ending or BOM check, normalization, repair, phase, +artifact, rebuild, or summary in Claude Code. In particular: + +- ignore the tracked-text line-ending validation item in `process-inbox`; +- skip perform-task Phase 7 entirely on every host; +- ignore the pipeline's Normalize step and every Phase 7 prompt, result, retry, + completion check, and reporting requirement; +- do not rewrite a file solely to change LF, CRLF, mixed endings, or BOM state. + +Let normal Claude editing preserve the checkout's existing text convention. +This exception removes only explicit line-ending work; it does not relax any +content validation, owned-path rule, build, review, test, or publication gate. + +## UI-driver capability + +The shared Computer Use reference describes Codex's driver. In Claude Code, +treat that particular driver as unavailable unless an equivalent UI-driver +tool is actually exposed in the current session. Preserve the same policy: +`auto` uses the already planned overlay fallback, while `required` reports the +exact unverified interaction. Driver availability never permits skipping the +ordinary overlay test loop, account setup, evidence, or safety rules.