mirror of
https://hubproxy.babadafafafafa.cn/https://github.com/telegramdesktop/tdesktop
synced 2026-09-20 08:03:45 +08:00
[ai] Consolidate discovered follow-ups.
This commit is contained in:
@@ -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.
|
||||
|
||||
244
.agents/skills/continue/references/consolidate-pending-tasks.md
Normal file
244
.agents/skills/continue/references/consolidate-pending-tasks.md
Normal 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.
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user