From a8def18edc4bf478cbf481a5fa119bb2e324a720 Mon Sep 17 00:00:00 2001 From: John Preston Date: Wed, 12 Aug 2026 10:33:00 +0400 Subject: [PATCH] [ai] Gate tasks on source branch lineage --- .agents/skills/continue/SKILL.md | 56 ++++++- .agents/skills/perform-task/SKILL.md | 16 ++ .../perform-task/references/pipeline.md | 28 +++- .agents/skills/process-inbox/SKILL.md | 15 +- .../skills/process-inbox/scripts/workspace.py | 144 ++++++++++++++++++ .../process-inbox/scripts/workspace_test.py | 138 +++++++++++++++++ 6 files changed, 389 insertions(+), 8 deletions(-) diff --git a/.agents/skills/continue/SKILL.md b/.agents/skills/continue/SKILL.md index f0a5779cf7..0acf088682 100644 --- a/.agents/skills/continue/SKILL.md +++ b/.agents/skills/continue/SKILL.md @@ -119,6 +119,45 @@ Do not write a batch file, claim the whole batch, or publish reservations. Queue refreshes update task state but never add ordinary task ids to the frozen batch. +### Source-lineage gate + +Before freezing the batch, inspect every prospective initial task's `task.md` +and dependencies. For every approved non-verification task whose shipped code +is a prerequisite, run: + +```bash +python3 .agents/skills/process-inbox/scripts/workspace.py source-lineage \ + --task [--require ...] +``` + +`depends_on` requirements are included automatically. Pass `--require` for an +explicit source prerequisite named in `task.md` that old routing failed to put +in `depends_on`. Unfinished dependencies remain a readiness concern and appear +separately; this gate checks the history of approved source work. + +If any prospective task reports `current_satisfies: false` at this startup +gate, pause before freezing, starting, retrying, resuming, or switching +branches. Report the current branch, missing source task ids, unavailable +commits, and compatible local branches, then ask the human whether to rebase, +bring the commit, switch the checkout, or change scope. Never create or route +an integration task, and never cherry-pick, rebase, merge, or switch branches +at this startup boundary. The exception is an already-active task whose saved +artifacts prove Phase 1 completed: it has crossed the task boundary, so resume +the performer and let the after-Phase-1 rule publish the task-local Block. + +After the batch is frozen, rerun the same gate immediately before each Start, +Retry, or pre-Phase-1 resume. A mismatch first found here is recoverable queue +routing, not a task blocker: while the selected task has not completed Phase 1, +switch this checkout to a compatible existing local branch and continue the +same frozen batch. Require a clean source checkout and submodules, no owned or +disposable task overlay, no exact checkout executable, no source recovery refs +for work already begun, and verify with `git worktree list --porcelain` that the +branch is not checked out elsewhere. Prefer a compatible branch appearing for +the most remaining batch tasks; preserve recorded batch order. Do not create a +branch or cherry-pick, rebase, or merge. After `git switch`, refresh `queue`, +rerun `source-lineage` and `source-preflight`, then Start/Retry or resume. If no +safe compatible local branch exists, stop and ask the human. + ### Mode 1: resume active work, then drain the selected snapshot If `own_in_progress` contains this checkout's active task, choose `active` @@ -211,7 +250,7 @@ dependency is `approved`. Add its id to the set, then reopen it locally: ```bash python3 .agents/skills/process-inbox/scripts/workspace.py retry \ - --task + --task [--require ...] ``` This preserves its ownership, source recovery refs, plans, reviews, tests, @@ -230,7 +269,7 @@ checkout whose id is in `batch_task_ids`, and start it: ```bash python3 .agents/skills/process-inbox/scripts/workspace.py start \ - --task + --task [--require ...] ``` The resulting canonical `Start` commit changes it to `in-progress`. Leave @@ -309,7 +348,11 @@ After it returns, require one of: An interruption or environment stop never becomes a convenience `Block`. After a genuine `Block`, add the task id to `attempted_blocked` and continue -with independent work. A dirty source checkout, a file-lock build failure that +with independent work. A source-lineage mismatch first proven after Phase 1 is +such a genuine task-local Block: continue with batch tasks that do not depend +on it and whose own lineage gates pass. A pre-Phase-1 lineage stop is not a +Block or global hard stop; apply the safe mid-queue branch-switch rule above and +resume the same performer. A dirty source checkout, a file-lock build failure that remains after `perform-task` exhausts the shared exact-checkout recovery, missing test account, unsafe publication conflict, or comparable global safety failure stops the loop. The first lock signature never stops the batch. @@ -352,6 +395,13 @@ those paths, commits helper. Retry ordinary concurrent-master races; preserve a semantic conflict or unavailable-remote slot commit and stop. +Never route discovered work whose sole purpose is moving an existing commit to +another branch: no backport, forward-port, cherry-pick, rebase, merge, or +branch-sync task. Record that request or observation in the discovery receipt +only, naming the source task and desired branch when known. A real product +follow-up may depend on the source task, but `depends_on` carries that lineage; +do not create an integration companion task. + Use this stable marker shape so a context-free worker can recover it: ```markdown diff --git a/.agents/skills/perform-task/SKILL.md b/.agents/skills/perform-task/SKILL.md index 3b137214da..91cce2963a 100644 --- a/.agents/skills/perform-task/SKILL.md +++ b/.agents/skills/perform-task/SKILL.md @@ -64,6 +64,11 @@ Inspect the resolved task, readiness, `other_active_task`, status, and owner. - If it is owned by another checkout, stop. Cross-checkout restart is a rare explicit human reassignment, never an implicit steal. - If its dependencies are unfinished, report them and stop without starting. +- Inspect `task.md` for approved source-task prerequisites in addition to + `depends_on`, then run `workspace.py source-lineage --task ` + with one `--require ` for each explicit prerequisite. Require + `current_satisfies: true` before Phase 1. For `start` or `retry`, pass the same + `--require` arguments so claiming is machine-gated too. - If it is `todo` and either unclaimed or owned by this checkout, atomically assign and activate it: @@ -89,6 +94,17 @@ Refresh with `resolve` after each mutation. The source pipeline begins only after the slot state shows this task `in-progress` for this checkout. For a new task, canonical master must already contain its `Start` commit. +A source-lineage mismatch found before Phase 1 is a pre-phase routing stop, not +a task `Block`: create no phase artifacts, source edits, retained commit, or +integration task. Return the lineage report to the `continue` scheduler, which +may safely switch an existing local branch and resume. In a direct interactive +invocation, report it and ask the human. If the mismatch is first discovered +only after Phase 1 has completed, restore every owned/disposable source change +to a clean boundary and publish a genuine `blocked` result naming the exact +missing source task and appropriate branch evidence. Do not cherry-pick, +rebase, merge, or manufacture the prerequisite. This blocker is task-local; +the scheduler may continue work that does not depend on it. + ## Run and publish Execute `references/pipeline.md` exactly. A normal task produces: diff --git a/.agents/skills/perform-task/references/pipeline.md b/.agents/skills/perform-task/references/pipeline.md index 8830a439f6..f2a2ac0d48 100644 --- a/.agents/skills/perform-task/references/pipeline.md +++ b/.agents/skills/perform-task/references/pipeline.md @@ -79,7 +79,19 @@ Before planning or editing: 1. Read `SOURCE_ROOT/AGENTS.md`, `REVIEW.md`, `AI_SLOT/AGENTS.md`, `TASK_SPEC`, every referenced input, and relevant project context. 2. Verify `state.yaml` is `in-progress` and owned by this checkout tag. -3. Run the scripted preflight report and act on its JSON instead of composing +3. Inspect `TASK_SPEC` for approved source-task prerequisites beyond + `depends_on`, then run the source-lineage gate before Phase 1: + + ```bash + python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \ + source-lineage --source-root SOURCE_ROOT --task TASK_ID \ + [--require EXPLICIT_SOURCE_TASK_ID ...] + ``` + + Require `current_satisfies: true`. A mismatch before Phase 1 returns the + clean pre-phase routing stop defined below; a mismatch first established + after Phase 1 follows the task-local Block rule. +4. Run the scripted preflight report and act on its JSON instead of composing the equivalent shell checks by hand: ```bash @@ -89,15 +101,15 @@ Before planning or editing: It reports source/submodule cleanliness, dirty paths outside the owned write set, and the golden test account and live marker state for `EXE`. -4. Require the prepared portable test account (`golden_account_present`). A +5. Require the prepared portable test account (`golden_account_present`). A `type: minimal` task runs no test loop and starts without it; for every other type its absence is a global hard stop before implementation. -5. Verify a usable Debug executable/build tree, safe path-scoped process +6. Verify a usable Debug executable/build tree, safe path-scoped process control, safe portable-folder operations, and the ability to launch and render the in-binary test flow. A locked macOS session disables Computer Use only; it does not fail this preflight or block testing, even when policy was `required`. -6. For a new run require a clean tracked Telegram worktree, clean submodules, +7. For a new run require a clean tracked Telegram worktree, clean submodules, and no unrelated untracked files, then initialize local recovery state: ```bash @@ -788,6 +800,14 @@ delays finishing the work actually in hand. ## Failure handling +- Source lineage has a strict timing boundary. Before Phase 1, a missing + approved prerequisite is a clean pre-phase routing stop: do not publish + `blocked`, edit source, or create a backport/cherry-pick/rebase/merge task. + The scheduler may switch to a compatible existing branch and resume. If the + missing prerequisite is first established after Phase 1 completed, restore + owned and disposable changes, publish a clean task-local `blocked` boundary + naming the missing source task and branch evidence, and let `continue` run + non-dependent batch work. Never perform branch integration inside the task. - A disposable phase may be retried once through the wait ladder. Never fresh retry the performer within the same attempt. An interruption leaves local task state `in-progress`; a later `continue` invocation resumes it. A later diff --git a/.agents/skills/process-inbox/SKILL.md b/.agents/skills/process-inbox/SKILL.md index cd05cc9062..eb26d5865a 100644 --- a/.agents/skills/process-inbox/SKILL.md +++ b/.agents/skills/process-inbox/SKILL.md @@ -76,6 +76,17 @@ inbox into requests, then decide for each request whether to: - add one or more tasks to an existing project; - create a new project when durable shared context is useful. +Never create a task whose work is to move an existing source commit between +branches: no backport, forward-port, cherry-pick, rebase, merge, branch sync, +or equivalent integration task. Branch placement is human release/history +coordination, not product work for the autonomous queue. When a request only +asks for that operation, record a receipt-only disposition naming the existing +source task or commit description and the requested target branch, then leave +the operation to the human. When new product work requires code shipped by an +earlier task, route only the product work and express the source task as a +dependency; do not create a companion task to bring that dependency onto a +branch. + Bias project assignment toward continuity. When a request follows from an existing task, begin with that task's project and keep it unless independence is affirmatively established. A task belongs to the existing project when it @@ -193,7 +204,9 @@ Use a project slug instead of `null` when routed to a project. Use a YAML list of task identifiers for dependencies. Dependencies record code lineage as well as readiness: keep an approved source task in `depends_on` when the new task's implementation assumes its shipped changes. State that prerequisite in -`task.md`. Inbox processing never reserves work: new tasks always remain +`task.md`. This dependency is sufficient; never add a separate backport, +cherry-pick, rebase, merge, or branch-sync task to make it reachable. Inbox +processing never reserves work: new tasks always remain `status: todo` with `claimed_by`, `claimed_at`, and `claim_order` set to `null`. The checkout tag belongs in the receipt only. diff --git a/.agents/skills/process-inbox/scripts/workspace.py b/.agents/skills/process-inbox/scripts/workspace.py index fab8baafb3..d638774b61 100755 --- a/.agents/skills/process-inbox/scripts/workspace.py +++ b/.agents/skills/process-inbox/scripts/workspace.py @@ -909,6 +909,24 @@ def command_start(args): ) if not task_ready(task, states): raise WorkspaceError(f"Task has unfinished dependencies: {args.task}") + lineage = source_lineage_report( + config, + slot, + args.task, + getattr(args, "require", []), + ) + if lineage.get("unfinished_dependencies"): + raise WorkspaceError( + "Task has unfinished explicit source dependencies: " + + ", ".join(lineage["unfinished_dependencies"]) + ) + if not lineage["current_satisfies"]: + branches = ", ".join(lineage["compatible_local_branches"]) or "none" + raise WorkspaceError( + "Task source dependencies are absent from the current branch: " + + ", ".join(lineage["missing_source_tasks"]) + + f"; compatible local branches: {branches}" + ) active = [ value["id"] for value in states.values() if value["claimed_by"] == config["checkout_tag"] @@ -962,6 +980,20 @@ def command_start(args): }, indent=2, sort_keys=True)) +def command_source_lineage(args): + config = worktree_config(args, create=True) + slot = Path(config["slot_worktree"]) + states = load_states(slot) + task = resolve_task(slot, states, args.task) + report = source_lineage_report( + config, + slot, + task["id"], + args.require, + ) + print(json.dumps(report, indent=2, sort_keys=True)) + + def command_retry(args): config = worktree_config(args, create=True) sync_canonical(config) @@ -979,6 +1011,24 @@ def command_retry(args): ) if not task_ready(task, states): raise WorkspaceError(f"Task has unfinished dependencies: {args.task}") + lineage = source_lineage_report( + config, + slot, + args.task, + getattr(args, "require", []), + ) + if lineage.get("unfinished_dependencies"): + raise WorkspaceError( + "Task has unfinished explicit source dependencies: " + + ", ".join(lineage["unfinished_dependencies"]) + ) + if not lineage["current_satisfies"]: + branches = ", ".join(lineage["compatible_local_branches"]) or "none" + raise WorkspaceError( + "Task source dependencies are absent from the current branch: " + + ", ".join(lineage["missing_source_tasks"]) + + f"; compatible local branches: {branches}" + ) active = [ value["id"] for value in states.values() if value["claimed_by"] == config["checkout_tag"] @@ -1081,6 +1131,92 @@ def task_series_refs(source, task_id): return None +def task_tip_commits(source, task_id): + commits = [ + commit for commit in run_git( + source, + "log", + "--all", + "--format=%H", + "--fixed-strings", + f"--grep=Task: {task_id}", + ).stdout.splitlines() + if task_commit_matches(source, commit, task_id) + ] + return [ + commit for commit in commits + if not any( + commit != other and is_ancestor(source, commit, other) + for other in commits + ) + ] + + +def source_lineage_report(config, slot, task_id, extra_requirements=()): + states = load_states(slot) + task = states.get(task_id) + if task is None: + raise WorkspaceError(f"Task does not exist: {task_id}") + superseded = load_superseded(slot) + required = [] + unfinished = [] + for dependency in (*task["depends_on"], *extra_requirements): + resolved = resolve_task_id(states, superseded, dependency)["id"] + state = states[resolved] + if state["status"] != "approved": + if resolved not in unfinished: + unfinished.append(resolved) + continue + if state["type"] == "verify" or resolved in required: + continue + required.append(resolved) + + source = Path(config["source_root"]) + tips = { + dependency: task_tip_commits(source, dependency) + for dependency in required + } + missing_history = [ + dependency for dependency, commits in tips.items() + if not commits + ] + missing = [ + dependency for dependency, commits in tips.items() + if not commits or not any(is_ancestor(source, commit) for commit in commits) + ] + branches = run_git( + source, + "for-each-ref", + "--format=%(refname:short)", + "refs/heads", + ).stdout.splitlines() + compatible = [ + branch for branch in branches + if all( + commits and any( + is_ancestor(source, commit, branch) + for commit in commits + ) + for commits in tips.values() + ) + ] + current_branch = run_git( + source, + "branch", + "--show-current", + ).stdout.strip() or None + return { + "task": task_id, + "current_branch": current_branch, + "current_satisfies": not missing, + "unfinished_dependencies": unfinished, + "required_source_tasks": required, + "missing_source_tasks": missing, + "unavailable_source_tasks": missing_history, + "compatible_local_branches": compatible, + } + + def is_ancestor(source, older, newer="HEAD"): return not run_git( source, @@ -3463,11 +3599,19 @@ def parse_args(): start = subparsers.add_parser("start") add_common_arguments(start) start.add_argument("--task", required=True) + start.add_argument("--require", action="append", default=[]) start.set_defaults(handler=command_start) + source_lineage = subparsers.add_parser("source-lineage") + add_common_arguments(source_lineage) + source_lineage.add_argument("--task", required=True) + source_lineage.add_argument("--require", action="append", default=[]) + source_lineage.set_defaults(handler=command_source_lineage) + retry = subparsers.add_parser("retry") add_common_arguments(retry) retry.add_argument("--task", required=True) + retry.add_argument("--require", action="append", default=[]) retry.set_defaults(handler=command_retry) checkpoint = subparsers.add_parser("checkpoint") diff --git a/.agents/skills/process-inbox/scripts/workspace_test.py b/.agents/skills/process-inbox/scripts/workspace_test.py index ac909d14ac..21f643ebbb 100644 --- a/.agents/skills/process-inbox/scripts/workspace_test.py +++ b/.agents/skills/process-inbox/scripts/workspace_test.py @@ -782,6 +782,11 @@ inbox_receipt: receipts/2026/07/20/race.md with ( mock.patch.object(workspace, "worktree_config", return_value=config), mock.patch.object(workspace, "sync_canonical"), + mock.patch.object( + workspace, + "source_lineage_report", + return_value={"current_satisfies": True}, + ), mock.patch.object(workspace, "commit_paths") as commit, contextlib.redirect_stdout(io.StringIO()), ): @@ -806,6 +811,11 @@ inbox_receipt: receipts/2026/07/20/race.md with ( mock.patch.object(workspace, "worktree_config", return_value=config), mock.patch.object(workspace, "sync_canonical"), + mock.patch.object( + workspace, + "source_lineage_report", + return_value={"current_satisfies": True}, + ), mock.patch.object(workspace, "commit_paths", return_value=True) as commit, contextlib.redirect_stdout(io.StringIO()), ): @@ -822,6 +832,124 @@ inbox_receipt: receipts/2026/07/20/race.md f"Start {TASK_ID} on macbook-twork", ) + def test_start_refuses_source_dependency_absent_from_branch(self): + with tempfile.TemporaryDirectory() as temporary: + slot = Path(temporary) + directory = write_task(slot, status="todo", claimed_by=None) + config = { + "checkout_tag": "macbook-twork", + "slot_worktree": str(slot), + } + report = { + "current_satisfies": False, + "missing_source_tasks": ["2026/07/18/source-task"], + "compatible_local_branches": ["layer229"], + } + with ( + mock.patch.object(workspace, "worktree_config", return_value=config), + mock.patch.object(workspace, "sync_canonical"), + mock.patch.object( + workspace, + "source_lineage_report", + return_value=report, + ) as lineage, + mock.patch.object(workspace, "commit_paths") as commit, + ): + with self.assertRaisesRegex( + workspace.WorkspaceError, + "compatible local branches: layer229", + ): + workspace.command_start(SimpleNamespace( + task=TASK_ID, + require=["2026/07/18/source-task"], + )) + + state = workspace.load_state(slot, directory / "state.yaml") + self.assertEqual(state["status"], "todo") + self.assertIsNone(state["claimed_by"]) + lineage.assert_called_once_with( + config, + slot, + TASK_ID, + ["2026/07/18/source-task"], + ) + commit.assert_not_called() + + def test_source_lineage_finds_compatible_local_branch(self): + dependency_id = "2026/07/18/source-task" + with tempfile.TemporaryDirectory() as temporary: + root = Path(temporary) + source = root / "source" + slot = root / "slot" + git_repo(source) + tracked = source / "tracked.txt" + tracked.write_text("base\n", encoding="utf-8") + git(source, "add", "tracked.txt") + git(source, "commit", "-m", "Create baseline") + git(source, "branch", "without-dependency") + tracked.write_text("source task\n", encoding="utf-8") + git( + source, + "commit", + "-am", + "Add source behavior", + "-m", + f"Task: {dependency_id}", + ) + git(source, "branch", "with-dependency") + git(source, "switch", "without-dependency") + + dependency = slot / "tasks" / dependency_id + dependency.mkdir(parents=True) + (dependency / "task.md").write_text( + "# Add source behavior\n", + encoding="utf-8", + ) + (dependency / "state.yaml").write_text( + """status: approved +type: implement +created: 2026-07-18 +project: null +depends_on: [] +claimed_by: macbook-twork +claimed_at: 2026-07-18T10:00:00+04:00 +claim_order: 1 +lease_until: null +phase: complete +inbox_receipt: receipts/2026/07/18/test.md +""", + encoding="utf-8", + ) + target = write_task(slot, status="todo", claimed_by=None) + state_path = target / "state.yaml" + state_path.write_text( + state_path.read_text(encoding="utf-8").replace( + "depends_on: []", + f"depends_on: [{dependency_id}]", + ), + encoding="utf-8", + ) + + config = {"source_root": str(source)} + report = workspace.source_lineage_report( + config, + slot, + TASK_ID, + ) + self.assertFalse(report["current_satisfies"]) + self.assertEqual(report["missing_source_tasks"], [dependency_id]) + self.assertEqual(report["unavailable_source_tasks"], []) + self.assertIn("with-dependency", report["compatible_local_branches"]) + + git(source, "switch", "with-dependency") + report = workspace.source_lineage_report( + config, + slot, + TASK_ID, + ) + self.assertTrue(report["current_satisfies"]) + self.assertEqual(report["missing_source_tasks"], []) + def test_checkpoint_updates_only_local_task_state(self): with tempfile.TemporaryDirectory() as temporary: slot = Path(temporary) @@ -866,6 +994,11 @@ inbox_receipt: receipts/2026/07/20/race.md with ( mock.patch.object(workspace, "worktree_config", return_value=config), mock.patch.object(workspace, "sync_canonical"), + mock.patch.object( + workspace, + "source_lineage_report", + return_value={"current_satisfies": True}, + ), mock.patch.object(workspace, "commit_paths", side_effect=record_commit), contextlib.redirect_stdout(io.StringIO()), ): @@ -943,6 +1076,11 @@ inbox_receipt: receipts/2026/07/19/test.md with ( mock.patch.object(workspace, "worktree_config", return_value=config), mock.patch.object(workspace, "sync_canonical"), + mock.patch.object( + workspace, + "source_lineage_report", + return_value={"current_satisfies": True}, + ), ): with self.assertRaisesRegex(workspace.WorkspaceError, "already in progress"): workspace.command_retry(SimpleNamespace(task=TASK_ID))