[ai] Consolidate discovered follow-ups.

This commit is contained in:
John Preston
2026-08-07 19:00:20 +04:00
parent ffd27d66bc
commit 1d7a6d5ff2
9 changed files with 1183 additions and 39 deletions

View File

@@ -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 resume the active task at the head of a frozen startup batch, drain matching queued work, or process the local inbox only when startup has no task work, while including follow-ups discovered from the batch but deferring unrelated tasks added mid-run.
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 resume the active task at the head of a frozen startup batch, drain matching queued work, or process the local inbox only when startup has no task work, while including and consolidating follow-ups discovered from the batch but deferring unrelated tasks added mid-run.
---
# Continue AI Work
@@ -8,8 +8,9 @@ description: Continue autonomous Telegram Desktop development from the shared ai
Act as the checkout-level scheduler. Choose one invocation mode at startup,
freeze its task batch, and keep looping only through that batch and follow-ups
discovered from its results. Do not drain unrelated tasks added while the run
is in progress. Delegate inbox planning and one-task execution; do not plan or
implement Telegram changes in this scheduler session.
is in progress. After routing new follow-ups, consolidate compatible unclaimed
work in a fresh leaf worker. Delegate inbox planning and one-task execution; do
not plan or implement Telegram changes in this scheduler session.
This is the default development command and the successor to the old `task`
and `implement` workflows. Inbox processing may bootstrap an otherwise idle
@@ -39,7 +40,9 @@ Restore those exact tracked paths to the slot branch head, delete their
untracked files, and continue. Stop instead of cleaning when any other slot
path changed, and never clean the main worktree or stash anywhere.
Unpublished clean AI slot commits are incomplete publication; run the
helper's `publish` command before selecting work.
helper's `publish` command before selecting work. It recognizes a consolidation
commit and revalidates its aliases and complete dependency graph after every
rebase before pushing; never publish such a commit manually.
The canonical lifecycle is deliberately small:
@@ -61,6 +64,18 @@ checkout. Moving an unfinished task to another checkout is a rare explicit
human reassignment that may restart the task and discard checkout-local phase
artifacts; it is never automatic scheduler behavior.
Before freezing a new invocation batch when no task is `in-progress`, handle
each entry in the queue JSON's `pending_consolidations` once. These durable
markers mean discovery routing published new tasks but its separate
consolidation pass did not finish. Spawn the consolidation worker described
below with the source task and batch ids recorded in the marker, then refresh
the queue. A repeated pre-commit race may remain pending for the next
invocation; record that marker as attempted and do not spin. If a task is
already active, its expected local phase files make the shared AI slot unsafe
for consolidation: freeze and finish that active task first, then recover all
pending markers at its clean canonical `Approve` or `Block` boundary before
selecting more work.
## Interpret scope hints
Treat text after `$continue` or `/continue` as optional natural-language
@@ -94,6 +109,7 @@ these invocation-local values in the scheduler plan:
- ordered `initial_batch_task_ids`;
- ordered `batch_task_ids`, initially equal to the initial batch;
- empty `discovered_task_ids`;
- empty `consolidation_mappings` and `consolidation_receipts`;
- empty `attempted_blocked`.
Do not write a batch file, claim the whole batch, or publish reservations.
@@ -316,14 +332,33 @@ Spawn one disposable routing worker with `fork_turns: "none"`. Tell it to read
the routing, splitting, task-path, artifact, validation, and publication rules
in `.agents/skills/process-inbox/SKILL.md`, but not to call `prepare`,
`finalize`, or `abort`. Its immutable input is the result, not the inbox. It
must not edit Telegram source, start tasks, or implement work.
must not edit Telegram source, start tasks, or implement work. Give it the
current ordered `batch_task_ids` for the pending consolidation marker.
The worker must deduplicate existing tasks, create independently testable
unclaimed `todo` tasks and justified project updates, write a discovery
receipt, and write the source task's routing marker. It stages only those
paths, commits `Route follow-ups from <source-task-id>`, and publishes with the
workspace helper. Retry ordinary concurrent-master races; preserve a semantic
conflict or unavailable-remote slot commit and stop.
receipt, and write the source task's routing marker. When it creates at least
one task, it must also write `work/consolidation-pending.md` under the source
task, recording the source id, newly created ids, and the post-routing batch:
the scheduler's current ordered `batch_task_ids` followed by the newly created
ids in routing order with duplicates removed. The marker is part of the routing
commit and makes the separate pass resumable after a crash. It stages only
those paths, commits
`Route follow-ups from <source-task-id>`, and publishes with the workspace
helper. Retry ordinary concurrent-master races; preserve a semantic conflict
or unavailable-remote slot commit and stop.
Use this stable marker shape so a context-free worker can recover it:
```markdown
# Pending task consolidation
Source: <source-task-id>
Created:
- <new-task-id>
Batch:
- <ordered-post-routing-batch-task-id>
```
Project assignment has a strong source-project bias. When the source task has
a project, assign each discovered implementation or verification task to that
@@ -407,6 +442,46 @@ transitively when a discovered task later reports its own follow-ups.
Deduplicated references to pre-existing tasks and unrelated tasks observed in
queue refreshes do not join the batch.
## Consolidate pending tasks after discovery
Whenever discovery routing publishes `work/consolidation-pending.md`, run one
fresh consolidation pass before selecting the next task. Do not run it for a
receipt-only routing or a routing that only reused existing tasks. Do not reuse
the performer or routing worker: spawn one disposable worker with
`fork_turns: "none"`, instruct it not to delegate, and give it `source_root`,
`slot_worktree`, `checkout_tag`, the pending marker, and effective batch ids.
Use the current frozen `batch_task_ids` when one exists; only recovery before a
new batch is frozen uses the marker's Batch list. The marker always supplies the
source task and newly created ids after scheduler context is lost.
Tell it to read
`.agents/skills/continue/references/consolidate-pending-tasks.md` completely and
own exactly one queue-wide consolidation pass. The worker may edit and publish
AI task, project, and receipt state, but must not touch Telegram source, build,
test, claim, start, approve, or block work. Wait and validate it like the routing
worker; keep its task-description scan and merge reasoning out of the scheduler
context.
A no-merge result still publishes `work/consolidation-complete.md` and removes
the pending marker, so it cannot be repeated after a restart. A pre-commit race
leaves the pending marker intact; refresh the queue, record it as attempted for
this invocation, and continue without treating the optimization as a blocker.
If the worker created a commit that cannot be published safely, preserve it and
hard-stop exactly as for discovery routing.
At every clean canonical `Approve` or `Block` boundary, process any older
pending marker deferred by an active startup task before selecting more work,
then process the marker just created by that task's routing. Attempt each marker
at most once per invocation.
For each published old-to-new mapping, rewrite `batch_task_ids` by placing the
replacement at the earliest position occupied by any of its sources and removing
the other source ids and duplicate replacement ids. Do not add a replacement
when none of its sources was in the batch. Apply the same replacement and
deduplication to `discovered_task_ids`; leave `initial_batch_task_ids` unchanged
as the startup record. Append the mapping and receipt to the invocation-local
consolidation records, refresh canonical state, and only then select more work.
## Report
Return one compact summary: invocation mode, initial batch ids, discovered ids
@@ -414,7 +489,8 @@ added to the batch, inbox receipt if processed, tasks approved, exceptionally
blocked tasks with exact unverified behavior and retry status, recorded tasks
left queued, unrelated new tasks deferred to the next invocation, routed
discoveries, infrastructure-limited coverage gaps recorded but not routed,
archived projects, any discarded interrupted-worker leftovers,
consolidation no-merge results or receipts, old-to-new mappings, the net
task-count saving, archived projects, any discarded interrupted-worker leftovers,
elapsed time, and why the loop stopped. Make any global hard stop or unsafe
state unmistakable. Never include source or AI commit hashes; task ids are the
only durable locators.

View File

@@ -0,0 +1,244 @@
# Consolidate pending AI tasks
Use one fresh leaf worker to reduce fixed per-task execution cost after newly
discovered follow-ups reach canonical AI state. Prefer a smaller queue when one
context pass, plan, review, build, fixture, and test run can prove several close
requests without weakening any of them.
## Contents
- [Inputs and boundary](#inputs-and-boundary)
- [Inventory and eligibility](#inventory-and-eligibility)
- [Aggressive merge decision](#aggressive-merge-decision)
- [Build replacement tasks](#build-replacement-tasks)
- [Traceability and validation](#traceability-and-validation)
- [Publish and return](#publish-and-return)
## Inputs and boundary
Receive the source checkout, AI slot worktree, checkout tag, the source task's
`work/consolidation-pending.md`, and the scheduler's effective ordered batch
ids. When a current invocation batch is already frozen, its `batch_task_ids`
are authoritative. Only recovery before a new batch is frozen uses the marker's
Batch list, which already includes every Created id. Read the discovery source
task and newly routed ids from the marker. Do not delegate. Do not read or
modify Telegram source, build, test, inbox, claim, or task execution state.
Work only in the clean checkout-specific AI slot after the discovery-routing
commit has published.
This is one queue-wide optimization pass, not another discovery planner. It may
replace compatible unclaimed tasks and update their direct dependents, project
indexes, and one new consolidation receipt. It must not invent new work, alter
approved history, create or move projects, or change the meaning of a request.
## Inventory and eligibility
Refresh canonical AI state. Inventory every task's id, status, ownership, type,
project, dependencies, and tracked task-directory contents. Partition all
mergeable work by this exact key:
1. the same project slug, or `project: null` for every member;
2. the same `type` (`implement` or `verify`);
3. the same scheduler membership: every member is in the current batch, or no
member is in it.
Never merge across projects, between a project and standalone work, across task
types, or across the frozen-batch boundary. Named projects and standalone work
are separate lineages even when their files overlap.
A candidate must be `status: todo`, `claimed_by: null`, with `claimed_at`,
`claim_order`, `lease_until`, and `phase` all `null`. It must have no tracked
`work/` or `evidence/` and no unexpected task-local artifact suggesting someone
has begun it. Inputs are allowed only when every pertinent file can be copied
and relinked without loss.
Read `task.md` and `state.yaml` completely for every member of each partition
containing at least two candidates. Read that project's `project.md` and
`tasks.md`, or the complete standalone candidate set. Inspect dependency and
reverse-dependency state. A candidate referenced by an `in-progress`, `blocked`,
approved, claimed, or otherwise non-mergeable task is ineligible; unclaimed
`todo` dependents may be rewritten atomically with the replacement.
Before selecting a cluster, simulate its replacement in the complete dependency
graph: remove the cluster, add the stable union of its external dependencies,
and rewrite every eligible dependent. Reject a cluster if the replacement would
depend directly or transitively on one of those dependents or if the simulated
graph contains any cycle. Dependency similarity never overrides this rule.
## Aggressive merge decision
Within an eligible partition, bias toward merging. Two or more tasks belong in
one replacement when they are close enough that a single execution can reuse a
material part of its context, implementation or measurement setup. Strong merge
signals include:
- the same component, control flow, surface, source seam, or expected files;
- one fixture, account state, overlay, process lifetime, or UI navigation can
exercise all acceptance criteria;
- one implementation naturally establishes several requested invariants;
- verification tasks measure the same parent diff, state machine, or tightly
related surfaces from one instrumented run;
- the combined work can use one coherent plan, review, Debug build, and test
loop instead of merely running unrelated jobs back to back.
Merge more than two tasks whenever those signals hold for the whole cluster.
The default question is why the tasks still need separate multi-hour pipelines,
not why they happen to resemble each other.
Keep tasks separate when the merged title and plan would be artificial, the
parts need independent designs or incompatible fixtures, one part materially
interferes with another's measurement, the work has a real sequential product
boundary, or the result would no longer fit one normal implementation or
verification pass. Same project alone is not enough. Record close candidates
left separate and the concrete reason; do not use vague labels such as
"unrelated" or "too large".
For `verify` clusters, retain each claim's own parent-diff boundary and revert
test. A union of dependencies or paths is not a new scope boundary. Combining
verification saves setup; it never widens what shipped behavior is owed and
never permits a source change. Do not combine `verify` with `implement`; a
verification finding routes its repair through the normal later pipeline.
## Build replacement tasks
Create one new dated task for each selected cluster. There is no `superseded`
status, but old task ids are durable: never delete their directories. Use a
concise imperative slug with the normal same-day collision suffix and write
canonical `task.md` plus `state.yaml`:
- `status: todo`, the shared source `type` and `project`, and all ownership and
phase fields `null`;
- `created` equal to the local consolidation date;
- `depends_on` equal to the stable union of external source dependencies, with
duplicates and every internally superseded id removed;
- `inbox_receipt` pointing to the new consolidation receipt.
Immediately before retirement, obtain each source specification fingerprint
from the helper while its live `state.yaml` still exists:
```bash
python3 .agents/skills/process-inbox/scripts/workspace.py task-content-digest \
--task <source-task-id>
```
Retire each source by removing only its `state.yaml`, retaining its original
`task.md` and `input/`, and adding this exact `superseded.yaml`:
```yaml
superseded_by: YYYY/MM/DD/replacement-task
receipt: receipts/YYYY/MM/DD/consolidation-receipt.md
type: implement
project: project-slug
content_sha256: <helper-output>
```
Use the actual shared type and `project: null` where appropriate. The queue sees
only directories with `state.yaml`, while the workspace resolver follows
`superseded.yaml` chains. This preserves old receipt links, deduplication paths,
human bookmarks, original wording, and inputs without leaving duplicate live
work. The digest covers every retained file path and byte except `state.yaml`
and `superseded.yaml`; post-rebase validation rejects a late task or input edit.
Never put execution artifacts into a retired directory.
Organize the combined body and acceptance by named parts when that keeps source
boundaries visible. Preserve every unique acceptance criterion and every
load-bearing constraint, prerequisite, safety rule, visual basis, fixture fact,
and input. Exact duplicate criteria may collapse to the strongest version only
when the receipt maps every source criterion to it and explains why nothing was
lost. Never generalize precise readings into a weaker umbrella criterion.
For a combined verification with several parent tasks, give each part its own
scope boundary and dependency statement. Explicitly state that their union is
not the boundary of any individual claim. Preserve the no-implementation and
no-Telegram-commit contract once for the whole task.
Copy supplied files into the replacement's `input/` using collision-safe names,
rewrite all links, and map every old file to the new path in the receipt. If an
input cannot be preserved, exclude that cluster. Rewrite eligible external
dependents' dependency lists and prerequisite prose from old ids to the new id.
For a named project, replace the old links with the new link in `tasks.md` and
make nearby durable narrative coherent. Do not store live status there. Do not
edit earlier receipts, approved task artifacts, or the discovery-routing marker;
they are immutable history. Repeat the complete old-to-new mapping in the
replacement task, each durable alias, and the consolidation receipt.
## Traceability and validation
Write one concise receipt under `receipts/YYYY/MM/DD/` named for the checkout
and consolidation time. Include:
- trigger source task, local time, checkout tag, and newly routed ids;
- the eligible inventory and every selected or rejected close cluster;
- exact old-to-new mapping, project, type, and batch-membership class;
- shared setup that justifies each merge and the fixed-cost saving it creates;
- per-criterion and load-bearing-context accounting;
- dependency unions, dependent rewrites, inputs, project-index changes, and all
paths created, changed, or removed.
Keep the proposal in worker context or an ignored temporary file. Immediately
before writing tracked files, refresh canonical state and re-read every source
and rewritten dependent. If any status, owner, type, project, dependency, or
tracked task contents changed, write nothing and return `RACED`, leaving the
pending marker for a later invocation. Apply all selected clusters only after
this safety check; never produce a partial consolidation.
Validate that every replacement is an unclaimed `todo`, every source criterion
and input is accounted for, all dependencies and links exist, project/type
boundaries hold, no old id remains as a live dependency or project link, every
alias chain reaches a live task, every retained-content fingerprint still
matches, and the complete live dependency graph is acyclic. Preserve native
line endings without a BOM. Never record source or AI commit hashes.
Whether or not anything merged, remove `work/consolidation-pending.md` and write
`work/consolidation-complete.md` under the source task. It must name the source,
newly routed ids, local time, examined partitions, concrete reasons for close
candidates left separate, receipt or `none`, mappings or `none`, and exactly one
of `STATUS: MERGED` or `STATUS: NO_MERGE`. This marker is the durable no-repeat
boundary. Only explicit task, project, receipt, and source-marker paths may
change.
## Publish and return
Do not stage or commit manually. Publish through the dedicated helper, passing
every changed source task, retired task, replacement task, rewritten dependent,
project, and receipt path explicitly. Pass one mapping for every retired id:
```bash
python3 .agents/skills/process-inbox/scripts/workspace.py consolidate-publish \
--source-task <source-task-id> \
--receipt <receipts/YYYY/MM/DD/name.md> \
--mapping <old-task-id>=<replacement-task-id> \
--path <tasks/YYYY/MM/DD/source-task> \
--path <tasks/YYYY/MM/DD/old-task> \
--path <tasks/YYYY/MM/DD/replacement-task> \
--path <receipts/YYYY/MM/DD/name.md>
```
For `NO_MERGE`, omit `--receipt` and `--mapping` and pass the source task path
covering the marker change. The helper rejects changes outside the explicit
paths, validates aliases, retained-content fingerprints, and the full dependency
graph, stages the paths, and commits once as
`Consolidate pending tasks after <source-task-id>`. After every fetch and rebase
it repeats validation immediately before its push; the push is the
compare-and-swap boundary. A concurrent task that still names a retired id, a
late source-specification edit, a new cycle, a semantic conflict, or a remote
outage therefore preserves the commit and returns `BLOCKED`; never force-push.
The generic `publish` command recognizes and applies the same validator when
resuming this commit.
Return only this compact contract to the scheduler:
```text
STATUS: MERGED | NO_MERGE | RACED | BLOCKED
Receipt: <path or none>
Mappings: <old -> new pairs or none>
Created: <count>
Retired: <count>
Net: <retired minus created>
```
`NO_MERGE` publishes only the completion boundary. Pre-commit `RACED` leaves the
pending marker intact and is safe to defer. `BLOCKED` is reserved for a committed
or otherwise unsafe publication state that the scheduler must preserve and
report.

View File

@@ -7,7 +7,9 @@ description: Resolve, start or resume, implement, commit, and verify exactly one
Own exactly one task through a Telegram commit and a canonical AI `Approve` or
exceptional `Block`. Do not process the inbox, split the task, drain the queue,
or select a follow-up afterward.
select a follow-up, or consolidate pending tasks afterward. The `continue`
scheduler isolates discovery routing and queue consolidation in fresh workers
after this performer returns.
## Read the complete engine

View File

@@ -54,6 +54,13 @@ Read these before planning:
`projects/archive/`, and relevant task states from `<inbox_worktree>`;
- the transaction's `inbox.md` and every file it references.
Some retained task directories have `superseded.yaml` instead of `state.yaml`.
They are durable aliases created by queue consolidation, not missing or reusable
paths. Follow `superseded_by` chains to their live task when deduplicating,
resolving prior receipt references, or checking whether a same-digest result
still exists. New dependencies and project links must name the final live task,
never an alias. A dated slug occupied by an alias still counts as a collision.
Use one disposable leaf planner when the harness supports delegation; instruct
it not to delegate. Otherwise perform the same work locally. The planner may
write a proposed routing file inside the ignored transaction, but only the
@@ -209,8 +216,9 @@ Create one tracked Markdown receipt under `receipts/YYYY/MM/DD/`. Include:
- deduplication decisions.
Before writing, search receipts for the same digest. If it was already fully
processed and all referenced tasks still exist, create nothing and reuse that
receipt for finalization.
processed and every referenced task either has live state or has a durable alias
chain reaching live state, create nothing and reuse that receipt for
finalization.
## Validate and publish

View File

@@ -18,10 +18,13 @@ TAG_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
TASK_ID_PATTERN = re.compile(
r"[0-9]{4}/[0-9]{2}/[0-9]{2}/[a-z0-9][a-z0-9-]*"
)
SHA256_PATTERN = re.compile(r"[0-9a-f]{64}")
VALID_STATUSES = {"todo", "in-progress", "approved", "blocked"}
DEFAULT_TASK_TYPE = "implement"
VALID_TASK_TYPES = {DEFAULT_TASK_TYPE, "verify"}
VALID_FINDINGS = {"confirmed", "deviation", "inconclusive"}
CONSOLIDATION_PENDING = "work/consolidation-pending.md"
CONSOLIDATION_COMPLETE = "work/consolidation-complete.md"
COMMIT_HASH_PATTERN = re.compile(
r"(?i)\b(?:commit|revision|sha(?:-1)?)\b[^\r\n]{0,32}(?<!#)\b[0-9a-f]{7,64}\b"
)
@@ -400,6 +403,7 @@ def load_state(root, path):
"claim_order": order,
"lease_until": parse_scalar(values.get("lease_until", "null")),
"phase": parse_scalar(values.get("phase", "null")),
"inbox_receipt": parse_scalar(values.get("inbox_receipt", "null")),
"state_path": str(path),
}
@@ -538,7 +542,7 @@ def sync_inbox_canonical(config):
sync_inbox_worktree(config)
def publish_worktree(config, worktree_key, branch_key, label):
def publish_worktree(config, worktree_key, branch_key, label, validate=None):
main = Path(config["ai_main"])
worktree = Path(config[worktree_key])
branch = config[branch_key]
@@ -553,6 +557,8 @@ def publish_worktree(config, worktree_key, branch_key, label):
"AI state conflicts with newer master; the worktree commits were preserved. "
+ (rebase.stderr.strip() or rebase.stdout.strip())
)
if validate is not None:
validate(worktree)
if has_origin(main):
push = run_git(worktree, "push", "origin", "HEAD:master", check=False)
if push.returncode:
@@ -579,12 +585,13 @@ def publish_worktree(config, worktree_key, branch_key, label):
)
def publish_slot(config):
def publish_slot(config, validate=None):
return publish_worktree(
config,
"slot_worktree",
"slot_branch",
"ai-tdesktop slot",
validate,
)
@@ -618,11 +625,112 @@ def normalized_task_name(value):
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-")
def resolve_task(states, value):
def superseded_paths(root):
tasks = root / "tasks"
if not tasks.is_dir():
return []
return sorted(tasks.glob("*/*/*/*/superseded.yaml"))
def retained_task_digest(directory):
if not directory.is_dir():
raise WorkspaceError(f"Task directory does not exist: {directory}")
digest = hashlib.sha256()
paths = []
for path in directory.rglob("*"):
if path.is_symlink():
raise WorkspaceError(f"Task retained content must not be a symlink: {path}")
if not path.is_file():
continue
relative = path.relative_to(directory).as_posix()
if relative in ("state.yaml", "superseded.yaml"):
continue
paths.append((relative, path))
for relative, path in sorted(paths):
name = relative.encode("utf-8")
data = path.read_bytes()
digest.update(len(name).to_bytes(8, "big"))
digest.update(name)
digest.update(len(data).to_bytes(8, "big"))
digest.update(data)
return digest.hexdigest()
def load_superseded(root):
result = {}
for path in superseded_paths(root):
values = {}
for line in path.read_text(encoding="utf-8-sig").splitlines():
if not line or line[0].isspace() or ":" not in line:
continue
key, value = line.split(":", 1)
values[key] = parse_scalar(value)
old_id = "/".join(path.relative_to(root).parts[1:5])
if not TASK_ID_PATTERN.fullmatch(old_id):
raise WorkspaceError(f"Invalid superseded task path: {path}")
for field in (
"superseded_by",
"receipt",
"type",
"project",
"content_sha256",
):
if field not in values:
raise WorkspaceError(f"Missing {field} in {path}")
target = values["superseded_by"]
if not isinstance(target, str) or not TASK_ID_PATTERN.fullmatch(target):
raise WorkspaceError(f"Invalid superseded_by in {path}: {target!r}")
kind = values["type"]
if kind not in VALID_TASK_TYPES:
raise WorkspaceError(f"Invalid type in {path}: {kind!r}")
project = values["project"]
if project is not None and not TAG_PATTERN.fullmatch(str(project)):
raise WorkspaceError(f"Invalid project in {path}: {project!r}")
if not isinstance(values["receipt"], str):
raise WorkspaceError(f"Invalid receipt in {path}: {values['receipt']!r}")
content_digest = values["content_sha256"]
if (
not isinstance(content_digest, str)
or not SHA256_PATTERN.fullmatch(content_digest)
):
raise WorkspaceError(
f"Invalid content_sha256 in {path}: {content_digest!r}"
)
result[old_id] = {
"id": old_id,
"superseded_by": target,
"receipt": values["receipt"],
"type": kind,
"project": project,
"content_sha256": content_digest,
"path": str(path),
}
return result
def resolve_task_id(states, superseded, task_id):
visited = []
current = task_id
while current not in states:
if current in visited:
raise WorkspaceError(
"Superseded task cycle: " + " -> ".join(visited + [current])
)
visited.append(current)
alias = superseded.get(current)
if alias is None:
raise WorkspaceError(f"Task does not exist: {task_id}")
current = alias["superseded_by"]
result = dict(states[current])
if current != task_id:
result["superseded_from"] = task_id
return result
def resolve_task(root, states, value):
superseded = load_superseded(root)
if TASK_ID_PATTERN.fullmatch(value):
if value not in states:
raise WorkspaceError(f"Task does not exist: {value}")
return states[value]
return resolve_task_id(states, superseded, value)
name = normalized_task_name(value)
if not name:
raise WorkspaceError("Task name is empty")
@@ -635,6 +743,16 @@ def resolve_task(states, value):
task for task in states.values()
if normalized_task_name(task["title"]) == name
]
if not exact:
alias_ids = [
task_id for task_id in superseded
if task_id.rsplit("/", 1)[-1] == name
]
exact = [
resolve_task_id(states, superseded, task_id)
for task_id in alias_ids
]
exact = list({task["id"]: task for task in exact}.values())
unfinished = [
task for task in exact
if task["status"] in ("todo", "in-progress", "blocked")
@@ -650,6 +768,25 @@ def resolve_task(states, value):
return candidates[0]
def pending_consolidations(root):
tasks = root / "tasks"
if not tasks.is_dir():
return []
result = []
for marker in sorted(tasks.glob(
"*/*/*/*/" + CONSOLIDATION_PENDING
)):
parts = marker.relative_to(root).parts
task_id = "/".join(parts[1:5])
if not TASK_ID_PATTERN.fullmatch(task_id):
raise WorkspaceError(f"Invalid consolidation marker path: {marker}")
result.append({
"source_task": task_id,
"marker": marker.relative_to(root).as_posix(),
})
return result
def command_queue(args):
config = worktree_config(args, create=True)
main = Path(config["ai_main"])
@@ -710,6 +847,7 @@ def command_queue(args):
"own_todo": own_todo,
"own_blocked": own_blocked,
"unclaimed_todo": unclaimed_todo,
"pending_consolidations": pending_consolidations(slot),
"other_claimed_unfinished": sum(
1 for task in values
if task["claimed_by"] not in (None, tag)
@@ -735,7 +873,7 @@ def command_resolve(args):
if not changed_paths(slot) and not unpublished_counts(config)["slot_only"]:
sync_canonical(config)
states = load_states(slot)
task = task_summary(resolve_task(states, args.name), states)
task = task_summary(resolve_task(slot, states, args.name), states)
active = sorted(
value["id"] for value in states.values()
if value["claimed_by"] == config["checkout_tag"]
@@ -853,9 +991,14 @@ def command_retry(args):
"phase": "resume",
"lease_until": None,
})
routed = path.parent / "work" / "discovered-routed.md"
if routed.is_file():
routed.unlink()
for marker in (
"work/discovered-routed.md",
CONSOLIDATION_PENDING,
CONSOLIDATION_COMPLETE,
):
marker_path = path.parent / marker
if marker_path.is_file():
marker_path.unlink()
print(json.dumps({
"task": args.task,
"status": "in-progress",
@@ -2421,7 +2564,9 @@ def command_finish(args):
def command_publish(args):
config = worktree_config(args, create=True)
published = publish_slot(config)
slot = Path(config["slot_worktree"])
validate = consolidation_validation_for_head(slot)
published = publish_slot(config, validate=validate)
print(json.dumps({"published": bool(published)}, indent=2, sort_keys=True))
@@ -2452,6 +2597,395 @@ def path_is_covered(path, roots):
return any(path == root or path.startswith(root + "/") for root in roots)
def parse_consolidation_mapping(value):
parts = value.split("=", 1)
if (
len(parts) != 2
or not TASK_ID_PATTERN.fullmatch(parts[0])
or not TASK_ID_PATTERN.fullmatch(parts[1])
or parts[0] == parts[1]
):
raise WorkspaceError(
f"Invalid consolidation mapping {value!r}; use old-task=new-task"
)
return tuple(parts)
def validate_dependency_graph(states):
dependents = {task_id: [] for task_id in states}
remaining = {}
for task in states.values():
missing = [
dependency for dependency in task["depends_on"]
if dependency not in states
]
if missing:
raise WorkspaceError(
f"{task['id']} has missing dependencies: " + ", ".join(missing)
)
dependencies = set(task["depends_on"])
remaining[task["id"]] = len(dependencies)
for dependency in dependencies:
dependents[dependency].append(task["id"])
ready = [task_id for task_id, count in remaining.items() if count == 0]
processed = 0
while ready:
task_id = ready.pop()
processed += 1
for dependent in dependents[task_id]:
remaining[dependent] -= 1
if remaining[dependent] == 0:
ready.append(dependent)
if processed != len(states):
cycle_members = sorted(
task_id for task_id, count in remaining.items()
if count
)
raise WorkspaceError(
"Task dependency cycle includes: " + ", ".join(cycle_members)
)
def validate_superseded_graph(root, states, superseded):
for task_id, alias in superseded.items():
if task_id in states:
raise WorkspaceError(
f"Superseded task still has live state.yaml: {task_id}"
)
directory = root / "tasks" / task_id
if not (directory / "task.md").is_file():
raise WorkspaceError(f"Superseded task lost task.md: {task_id}")
actual_digest = retained_task_digest(directory)
if actual_digest != alias["content_sha256"]:
raise WorkspaceError(
f"Superseded task retained content changed: {task_id}"
)
current = task_id
visited = []
while current not in states:
if current in visited:
raise WorkspaceError(
"Superseded task cycle: " + " -> ".join(visited + [current])
)
visited.append(current)
current_alias = superseded.get(current)
if current_alias is None:
raise WorkspaceError(
f"Superseded task {task_id} targets missing task {current}"
)
current = current_alias["superseded_by"]
def validate_consolidation_tree(root, source_task, mappings, receipt):
states = load_states(root)
superseded = load_superseded(root)
if source_task not in states:
raise WorkspaceError(
f"Consolidation source task does not exist: {source_task}"
)
if states[source_task]["status"] not in ("approved", "blocked"):
raise WorkspaceError(
f"Consolidation source task is not finished: {source_task}"
)
source = root / "tasks" / source_task
pending = source / CONSOLIDATION_PENDING
complete = source / CONSOLIDATION_COMPLETE
if pending.exists():
raise WorkspaceError(f"Consolidation pending marker remains: {pending}")
if not complete.is_file():
raise WorkspaceError(f"Missing consolidation completion marker: {complete}")
complete_text = complete.read_text(encoding="utf-8-sig")
expected_status = "MERGED" if mappings else "NO_MERGE"
if source_task not in complete_text or f"STATUS: {expected_status}" not in complete_text:
raise WorkspaceError(
f"Consolidation completion marker must name {source_task} and "
f"contain STATUS: {expected_status}"
)
validate_dependency_graph(states)
validate_superseded_graph(root, states, superseded)
if not mappings:
if receipt is not None:
raise WorkspaceError("A no-merge consolidation must not publish a receipt")
return
if receipt is None:
raise WorkspaceError("A merged consolidation requires a receipt")
receipt_path = root / receipt
if not receipt_path.is_file():
raise WorkspaceError(f"Missing consolidation receipt: {receipt}")
receipt_text = receipt_path.read_text(encoding="utf-8-sig")
targets = {}
for old_id, new_id in mappings.items():
alias = superseded.get(old_id)
if alias is None or alias["superseded_by"] != new_id:
raise WorkspaceError(
f"Missing exact superseded mapping {old_id} -> {new_id}"
)
if alias["receipt"] != receipt:
raise WorkspaceError(
f"Superseded task {old_id} names the wrong receipt"
)
new_task = states.get(new_id)
if new_task is None:
raise WorkspaceError(f"Replacement task does not exist: {new_id}")
if (
new_task["status"] != "todo"
or any(new_task[field] is not None for field in (
"claimed_by",
"claimed_at",
"claim_order",
"lease_until",
"phase",
))
):
raise WorkspaceError(
f"Replacement task is not pristine unclaimed todo work: {new_id}"
)
if (
alias["type"] != new_task["type"]
or alias["project"] != new_task["project"]
):
raise WorkspaceError(
f"Replacement changes type or project for {old_id}: {new_id}"
)
if new_task["inbox_receipt"] != receipt:
raise WorkspaceError(f"Replacement task names the wrong receipt: {new_id}")
if old_id not in receipt_text or new_id not in receipt_text:
raise WorkspaceError(
f"Consolidation receipt omits mapping {old_id} -> {new_id}"
)
targets.setdefault(new_id, []).append(old_id)
for new_id, old_ids in targets.items():
if len(old_ids) < 2:
raise WorkspaceError(
f"Replacement {new_id} consolidates fewer than two tasks"
)
for project_path in sorted((root / "projects").glob("*/tasks.md")):
text = project_path.read_text(encoding="utf-8-sig")
for old_id in mappings:
if f"tasks/{old_id}/task.md" in text:
raise WorkspaceError(
f"Project index still links superseded task {old_id}: {project_path}"
)
def consolidation_validation_for_head(slot):
subject = run_git(slot, "show", "-s", "--format=%s", "HEAD").stdout.strip()
prefix = "Consolidate pending tasks after "
if not subject.startswith(prefix):
return None
source_task = subject[len(prefix):]
if not TASK_ID_PATTERN.fullmatch(source_task):
raise WorkspaceError(f"Invalid consolidation commit subject: {subject!r}")
changed = run_git(
slot,
"diff-tree",
"--no-commit-id",
"--name-only",
"-r",
"HEAD^",
"HEAD",
).stdout.splitlines()
superseded = load_superseded(slot)
mappings = {}
for path in changed:
if not path.endswith("/superseded.yaml"):
continue
parts = PurePosixPath(path).parts
if len(parts) != 6 or parts[0] != "tasks":
raise WorkspaceError(
f"Invalid superseded path in consolidation commit: {path}"
)
old_id = "/".join(parts[1:5])
alias = superseded.get(old_id)
if alias is None:
raise WorkspaceError(
f"Consolidation commit removed superseded alias: {old_id}"
)
mappings[old_id] = alias["superseded_by"]
receipts = {
superseded[old_id]["receipt"]
for old_id in mappings
}
if len(receipts) > 1:
raise WorkspaceError("Consolidation mappings name different receipts")
receipt = next(iter(receipts), None)
return lambda root: validate_consolidation_tree(
root,
source_task,
mappings,
receipt,
)
def command_task_content_digest(args):
config = worktree_config(args, create=True)
slot = Path(config["slot_worktree"])
path = state_path(slot, args.task).parent
print(json.dumps({
"content_sha256": retained_task_digest(path),
"task": args.task,
}, indent=2, sort_keys=True))
def command_consolidate_publish(args):
config = worktree_config(args, create=True)
slot = Path(config["slot_worktree"])
if not TASK_ID_PATTERN.fullmatch(args.source_task):
raise WorkspaceError(f"Invalid source task: {args.source_task!r}")
mappings = {}
for value in args.mappings:
old_id, new_id = parse_consolidation_mapping(value)
if old_id in mappings:
raise WorkspaceError(f"Duplicate consolidation source task: {old_id}")
mappings[old_id] = new_id
receipt = normalized_publish_path(args.receipt) if args.receipt else None
if receipt is not None and not receipt.startswith("receipts/"):
raise WorkspaceError("The consolidation receipt must be below receipts/")
if mappings and receipt is None:
raise WorkspaceError("A merged consolidation requires a receipt")
if not mappings and receipt is not None:
raise WorkspaceError("A no-merge consolidation must not publish a receipt")
paths = sorted({normalized_publish_path(value) for value in args.paths})
complete = f"tasks/{args.source_task}/{CONSOLIDATION_COMPLETE}"
if not path_is_covered(complete, paths):
raise WorkspaceError(
"The consolidation completion marker is not covered by a publication path"
)
if receipt is not None and not path_is_covered(receipt, paths):
raise WorkspaceError(
"The consolidation receipt is not covered by a publication path"
)
changes = changed_paths(slot)
unexpected = [path for path in changes if not path_is_covered(path, paths)]
if unexpected:
raise WorkspaceError(
"Consolidation changes are outside the explicit publication paths: "
+ ", ".join(unexpected)
)
changed_aliases = {
"/".join(PurePosixPath(path).parts[1:5])
for path in changes
if path.endswith("/superseded.yaml")
}
if changed_aliases != set(mappings):
raise WorkspaceError(
"Changed superseded aliases do not match explicit mappings: "
+ ", ".join(sorted(changed_aliases ^ set(mappings)))
)
for old_id in mappings:
required = {
f"tasks/{old_id}/state.yaml",
f"tasks/{old_id}/superseded.yaml",
}
if not required.issubset(changes):
raise WorkspaceError(
f"Consolidation does not retire {old_id} with state and alias changes"
)
old_prefix = f"tasks/{old_id}/"
unexpected_old = [
path for path in changes
if path.startswith(old_prefix) and path not in required
]
if unexpected_old:
raise WorkspaceError(
f"Consolidation modifies retained content for {old_id}: "
+ ", ".join(unexpected_old)
)
source_allowed = {
f"tasks/{args.source_task}/{CONSOLIDATION_PENDING}",
f"tasks/{args.source_task}/{CONSOLIDATION_COMPLETE}",
}
if changes and not source_allowed.issubset(changes):
raise WorkspaceError(
"Consolidation must replace the pending marker with a completion marker"
)
unexpected_source = [
path for path in changes
if path.startswith(f"tasks/{args.source_task}/")
and path not in source_allowed
]
if unexpected_source:
raise WorkspaceError(
"Consolidation modifies discovery source content: "
+ ", ".join(unexpected_source)
)
if not mappings:
unexpected_no_merge = [
path for path in changes
if path not in source_allowed
]
if unexpected_no_merge:
raise WorkspaceError(
"No-merge consolidation changes non-marker paths: "
+ ", ".join(unexpected_no_merge)
)
else:
for new_id in set(mappings.values()):
required = {
f"tasks/{new_id}/task.md",
f"tasks/{new_id}/state.yaml",
}
if not required.issubset(changes):
raise WorkspaceError(
f"Replacement task is not newly created: {new_id}"
)
if receipt not in changes:
raise WorkspaceError(
f"Consolidation receipt is not newly written: {receipt}"
)
counts = unpublished_counts(config)
if changes and counts["slot_only"]:
raise WorkspaceError(
"The AI slot has unpublished commits before consolidation staging"
)
validate = lambda root: validate_consolidation_tree(
root,
args.source_task,
mappings,
receipt,
)
validate(slot)
committed = False
if changes:
for path in paths:
if (
(slot / path).exists()
or any(path_is_covered(change, [path]) for change in changes)
):
run_git(slot, "add", "-A", "--", path)
unstaged = run_git(slot, "diff", "--name-only").stdout.splitlines()
untracked = run_git(
slot,
"ls-files",
"--others",
"--exclude-standard",
).stdout.splitlines()
if unstaged or untracked:
raise WorkspaceError(
"Consolidation changes remain unstaged: "
+ ", ".join(sorted(set(unstaged + untracked)))
)
if run_git(slot, "diff", "--cached", "--quiet", check=False).returncode == 0:
raise WorkspaceError("No consolidation state changed")
run_git(
slot,
"commit",
"-m",
f"Consolidate pending tasks after {args.source_task}",
)
committed = True
elif not counts["slot_only"]:
raise WorkspaceError("No consolidation changes or unpublished commit")
published = publish_slot(config, validate=validate)
print(json.dumps({
"committed": committed,
"mappings": mappings,
"published": bool(published),
"receipt": receipt,
"source_task": args.source_task,
}, indent=2, sort_keys=True))
def validate_receipt_text(text, metadata, label):
if metadata["digest"] not in text:
raise WorkspaceError(
@@ -3030,6 +3564,29 @@ def parse_args():
)
inbox_publish.set_defaults(handler=command_inbox_publish)
consolidate_publish = subparsers.add_parser("consolidate-publish")
add_common_arguments(consolidate_publish)
consolidate_publish.add_argument("--source-task", required=True)
consolidate_publish.add_argument("--receipt")
consolidate_publish.add_argument(
"--mapping",
action="append",
dest="mappings",
default=[],
)
consolidate_publish.add_argument(
"--path",
action="append",
dest="paths",
required=True,
)
consolidate_publish.set_defaults(handler=command_consolidate_publish)
task_content_digest = subparsers.add_parser("task-content-digest")
add_common_arguments(task_content_digest)
task_content_digest.add_argument("--task", required=True)
task_content_digest.set_defaults(handler=command_task_content_digest)
archive_stale = subparsers.add_parser("archive-stale")
add_common_arguments(archive_stale)
archive_stale.add_argument("--days", type=int, default=90)

View File

@@ -523,16 +523,258 @@ class WorkspaceTest(unittest.TestCase):
}
states = {task["id"]: task for task in (approved, blocked)}
resolved = workspace.resolve_task(states, "correct-recent-search-peer-actions")
with tempfile.TemporaryDirectory() as temporary:
resolved = workspace.resolve_task(
Path(temporary),
states,
"correct-recent-search-peer-actions",
)
self.assertEqual(resolved["id"], TASK_ID)
def test_resolve_follows_durable_superseded_task_alias(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
write_task(root, status="todo", claimed_by=None)
old_id = "2026/07/18/old-recent-search-task"
old = root / "tasks" / old_id
old.mkdir(parents=True)
(old / "task.md").write_text("# Old task\n", encoding="utf-8")
content_digest = workspace.retained_task_digest(old)
(old / "superseded.yaml").write_text(
f"""superseded_by: {TASK_ID}
receipt: receipts/2026/07/20/consolidation.md
type: implement
project: null
content_sha256: {content_digest}
""",
encoding="utf-8",
)
states = workspace.load_states(root)
resolved = workspace.resolve_task(root, states, old_id)
self.assertEqual(resolved["id"], TASK_ID)
self.assertEqual(resolved["superseded_from"], old_id)
def test_dependency_validation_rejects_missing_and_cyclic_edges(self):
first = {**task_state("todo", None), "depends_on": ["missing"]}
with self.assertRaisesRegex(workspace.WorkspaceError, "missing dependencies"):
workspace.validate_dependency_graph({TASK_ID: first})
other_id = "2026/07/20/other-task"
first = {**task_state("todo", None), "depends_on": [other_id]}
other = {
**task_state("todo", None),
"id": other_id,
"depends_on": [TASK_ID],
}
with self.assertRaisesRegex(workspace.WorkspaceError, "dependency cycle"):
workspace.validate_dependency_graph({TASK_ID: first, other_id: other})
def test_queue_inventory_finds_pending_consolidation_markers(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
directory = write_task(root, status="approved")
marker = directory / workspace.CONSOLIDATION_PENDING
marker.write_text("Created: one\n", encoding="utf-8")
self.assertEqual(workspace.pending_consolidations(root), [{
"source_task": TASK_ID,
"marker": f"tasks/{TASK_ID}/{workspace.CONSOLIDATION_PENDING}",
}])
def test_generic_publish_recognizes_consolidation_validation(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
git_repo(root)
directory = write_task(root, status="approved")
pending = directory / workspace.CONSOLIDATION_PENDING
pending.write_text("pending\n", encoding="utf-8")
git(root, "add", ".")
git(root, "commit", "-m", "Seed completed task")
pending.unlink()
complete = directory / workspace.CONSOLIDATION_COMPLETE
complete.write_text(
f"# Consolidation\n\nSource: {TASK_ID}\nSTATUS: NO_MERGE\n",
encoding="utf-8",
)
git(root, "add", "-A")
git(
root,
"commit",
"-m",
f"Consolidate pending tasks after {TASK_ID}",
)
validate = workspace.consolidation_validation_for_head(root)
self.assertIsNotNone(validate)
validate(root)
def test_no_merge_consolidation_publishes_durable_completion(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
config = inbox_worktrees(root)
main = Path(config["ai_main"])
slot = Path(config["slot_worktree"])
source_task = "2026/07/18/active-task"
directory = main / "tasks" / source_task
state = directory / "state.yaml"
state.write_text(
state.read_text(encoding="utf-8")
.replace("status: todo", "status: approved")
.replace("phase: null", "phase: complete"),
encoding="utf-8",
)
pending = directory / workspace.CONSOLIDATION_PENDING
pending.parent.mkdir()
pending.write_text("# Pending task consolidation\n", encoding="utf-8")
git(main, "add", f"tasks/{source_task}")
git(main, "commit", "-m", "Route follow-ups")
git(slot, "merge", "--ff-only", "master")
slot_directory = slot / "tasks" / source_task
(slot_directory / workspace.CONSOLIDATION_PENDING).unlink()
(slot_directory / workspace.CONSOLIDATION_COMPLETE).write_text(
f"# Consolidation\n\nSource: {source_task}\nSTATUS: NO_MERGE\n",
encoding="utf-8",
)
out = io.StringIO()
with (
mock.patch.object(workspace, "worktree_config", return_value=config),
contextlib.redirect_stdout(out),
):
workspace.command_consolidate_publish(SimpleNamespace(
source_task=source_task,
mappings=[],
receipt=None,
paths=[f"tasks/{source_task}"],
))
result = json.loads(out.getvalue())
self.assertTrue(result["committed"])
self.assertTrue(result["published"])
self.assertFalse(
(main / "tasks" / source_task / workspace.CONSOLIDATION_PENDING).exists()
)
self.assertTrue(
(main / "tasks" / source_task / workspace.CONSOLIDATION_COMPLETE).is_file()
)
def test_merged_consolidation_validates_aliases_and_dependencies(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source = write_task(root, status="approved")
(source / workspace.CONSOLIDATION_COMPLETE).write_text(
f"# Consolidation\n\nSource: {TASK_ID}\nSTATUS: MERGED\n",
encoding="utf-8",
)
old_ids = ["2026/07/18/first-task", "2026/07/18/second-task"]
new_id = "2026/07/20/combined-task"
receipt = "receipts/2026/07/20/consolidation.md"
for old_id in old_ids:
directory = root / "tasks" / old_id
directory.mkdir(parents=True)
(directory / "task.md").write_text(
f"# {old_id}\n", encoding="utf-8",
)
content_digest = workspace.retained_task_digest(directory)
(directory / "superseded.yaml").write_text(
f"""superseded_by: {new_id}
receipt: {receipt}
type: implement
project: null
content_sha256: {content_digest}
""",
encoding="utf-8",
)
new = root / "tasks" / new_id
new.mkdir(parents=True)
(new / "task.md").write_text("# Combined task\n", encoding="utf-8")
(new / "state.yaml").write_text(
f"""status: todo
type: implement
created: 2026-07-20
project: null
depends_on: []
claimed_by: null
claimed_at: null
claim_order: null
lease_until: null
phase: null
inbox_receipt: {receipt}
""",
encoding="utf-8",
)
receipt_path = root / receipt
receipt_path.parent.mkdir(parents=True)
receipt_path.write_text(
"\n".join(old_ids + [new_id]) + "\n",
encoding="utf-8",
)
mappings = {old_id: new_id for old_id in old_ids}
workspace.validate_consolidation_tree(
root,
TASK_ID,
mappings,
receipt,
)
(root / "tasks" / old_ids[0] / "task.md").write_text(
"# Late changed acceptance\n", encoding="utf-8",
)
with self.assertRaisesRegex(
workspace.WorkspaceError,
"retained content changed",
):
workspace.validate_consolidation_tree(
root,
TASK_ID,
mappings,
receipt,
)
(root / "tasks" / old_ids[0] / "task.md").write_text(
f"# {old_ids[0]}\n", encoding="utf-8",
)
dependent = root / "tasks" / "2026/07/20/racing-dependent"
dependent.mkdir(parents=True)
(dependent / "task.md").write_text(
"# Racing dependent\n", encoding="utf-8",
)
(dependent / "state.yaml").write_text(
f"""status: todo
type: implement
created: 2026-07-20
project: null
depends_on: [{old_ids[0]}]
claimed_by: null
claimed_at: null
claim_order: null
lease_until: null
phase: null
inbox_receipt: receipts/2026/07/20/race.md
""",
encoding="utf-8",
)
with self.assertRaisesRegex(workspace.WorkspaceError, "missing dependencies"):
workspace.validate_consolidation_tree(
root,
TASK_ID,
mappings,
receipt,
)
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")
pending = directory / workspace.CONSOLIDATION_PENDING
pending.write_text("pending\n", encoding="utf-8")
complete = directory / workspace.CONSOLIDATION_COMPLETE
complete.write_text("complete\n", encoding="utf-8")
config = {
"checkout_tag": "macbook-twork",
"slot_worktree": str(slot),
@@ -549,6 +791,8 @@ class WorkspaceTest(unittest.TestCase):
self.assertEqual(state["status"], "in-progress")
self.assertEqual(state["phase"], "resume")
self.assertFalse(routed.exists())
self.assertFalse(pending.exists())
self.assertFalse(complete.exists())
commit.assert_not_called()
def test_start_atomically_assigns_unclaimed_todo(self):

View File

@@ -12,8 +12,11 @@ This file adapts harness mechanics and removes unnecessary text normalization.
- 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.
- Tell every disposable phase leaf Agent not to delegate and never to commit.
Publication-owning orchestrators — `process-inbox`, `perform-task`, discovered
routing, and pending-task consolidation — follow the shared workflow's exact
helper, commit, and publication contract instead. Preserve its single-writer
and one-stateful-performer constraints.
- Every phase leaf and the performer inherit the parent model, as the shared
workflow says. Do not pass a model override on the Agent call: its family
aliases already resolve to the newest permitted model of that family, so an
@@ -44,13 +47,13 @@ This file adapts harness mechanics and removes unnecessary text normalization.
- 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`, or
discovered-task routing — the orchestrating roles — explicitly tell it to
read this adapter completely before the applicable shared skill or
reference. Do NOT tell leaf phase agents to read this adapter: their phase
prompts are self-contained and already carry the leaf rules (no delegation,
no commits, progress and reply contracts); an adapter read there is wasted
context.
- Whenever an Agent is asked to run `process-inbox`, `perform-task`,
discovered-task routing, or pending-task consolidation — the orchestrating
roles — explicitly tell it to read this adapter completely before the
applicable shared skill or reference. Do NOT tell leaf phase agents to read
this adapter: their phase prompts are self-contained and already carry the
leaf rules (no delegation, no commits, progress and reply contracts); an
adapter read there is wasted context.
## Text handling

View File

@@ -19,10 +19,19 @@ follow-up requires its affirmative independence test. Continue until that
frozen-and-derived batch reaches the scheduler's normal stop condition or a
global hard stop.
Every inbox worker, performer, and discovered-task routing Agent must be told
in its initial prompt to read `.claude/ai-workflow-adapter.md` completely before
the applicable shared skill or reference. Use the Agent tool for those workers;
do not start Claude subprocesses through Bash.
Apply the shared skill's pending-task consolidation phase too. At each eligible
clean AI-slot boundary, recover an older `pending_consolidations` marker or
consolidate newly routed tasks in one fresh foreground Agent. Give it the
effective frozen batch, keep its queue scan and merge reasoning outside this
scheduler context, and require it to read the complete shared consolidation
reference. Never run consolidation inside the performer or discovery-routing
Agent, and defer it while the active task owns dirty local phase state.
Every inbox worker, performer, discovered-task routing Agent, and pending-task
consolidation Agent must be told in its initial prompt to read
`.claude/ai-workflow-adapter.md` completely before the applicable shared skill
or reference. Use the Agent tool for those workers; do not start Claude
subprocesses through Bash.
Arguments, when present, are natural-language hints for new shared work; their
wording decides whether they merely prioritize or strictly restrict selection:

View File

@@ -9,7 +9,8 @@ Read `.claude/ai-workflow-adapter.md` and
`.agents/skills/perform-task/SKILL.md` completely. Follow the shared skill with
the Claude adapter's delegation and text-handling substitutions. Resolve, start
or resume, implement, verify, and publish only the named task. Do not
continue with other queue work afterward.
continue with other queue work, route discoveries, or consolidate pending tasks
afterward; those post-result phases belong to the `continue` scheduler.
Apply the shared pipeline's conditional `[ai] ` commit-subject rule exactly;
the prefix is required for permanent test-helper or agent harness/documentation