[ai] Gate tasks on source branch lineage

This commit is contained in:
John Preston
2026-08-12 10:33:00 +04:00
parent 51ca255a21
commit a8def18edc
6 changed files with 389 additions and 8 deletions

View File

@@ -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 <task-id> [--require <explicit-source-task-id> ...]
```
`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 <YYYY/MM/DD/slug>
--task <YYYY/MM/DD/slug> [--require <explicit-source-task-id> ...]
```
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 <YYYY/MM/DD/slug>
--task <YYYY/MM/DD/slug> [--require <explicit-source-task-id> ...]
```
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

View File

@@ -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 <full-task-id>`
with one `--require <source-task-id>` 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:

View File

@@ -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

View File

@@ -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.

View File

@@ -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")

View File

@@ -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))