[ai] Have semi-prepared testing run.

This commit is contained in:
John Preston
2026-07-28 10:45:48 +04:00
parent 7c86e5ddcd
commit 87a022ad1d
20 changed files with 2482 additions and 255 deletions

View File

@@ -110,6 +110,9 @@ The debug build runs in portable mode out of `out/Debug/`. Three sibling folders
**SETUP — run at the START of every test run, with NO app instance alive. Idempotent, and a pure
no-op between runs and between consecutive tasks (the marked test copy is simply reused).**
The workspace helper's `test-run` command performs exactly these steps before every launch, and
`test-account-reset` performs the broken-account recovery below; the manual steps remain the
contract those commands implement.
1. Require `test_TelegramForcePortable`. Its absence is the only portable-account setup blocker.
2. If `TelegramForcePortable/testing` exists, the live folder is already the reusable test copy:
touch none of the three folders and proceed straight to testing.
@@ -151,7 +154,8 @@ account, and MUST be left alive. On Windows, scope the kill by path:
`taskkill /IM Telegram.exe /F` is forbidden here and anywhere else in this loop — it is image-name-wide
and takes down the user's unrelated clients. Every "kill stragglers" / "taskkill" step below means
this path-scoped kill.
this path-scoped kill. The workspace helper's `test-run` and `test-cleanup` commands implement it
on every platform; prefer them over hand-written kill shell.
**Avoid account-fatal calls; cloud data is otherwise fair game.** The overlay must never trigger
logout / session-termination / account-deletion, and must not wipe the account wholesale. Tests that
@@ -263,48 +267,101 @@ How TEST verifies it (numbers over eyes):
## Overlay mechanics
The overlay is ad-hoc, authored fresh against the CURRENT implementation, injected at the
highest level that still exercises the change (often a direct data-layer call like
`item->applyEdition(...)` rather than a faked MTP response). It is also the complete runtime driver
of first resort: prefer programmatically triggering every required action and judging the saved logs
and captures afterwards over any external desktop driver, whether or not one is available. Drive the
whole task-specific flow inside the Debug binary by invoking application actions or posting Qt input events on the event loop, waiting for
observable state, logging assertions, capturing the rendered target in-process, and quitting. A
locked macOS session does not reduce required coverage and is never a testing blocker. The overlay
must:
The repository carries a permanent test harness under
`Telegram/SourceFiles/test/` — always compiled, runtime-gated on `-testagent`
(`Test::Active()`), with all of its `#ifdef`s inside the harness itself:
- Live entirely inside `#ifdef _DEBUG` blocks.
- `test_runner.h` — the staged scenario engine: `Stage{name, run, until, then, timeout}`,
`waitEvent`, `waitForSessionReady`, `waitForChatsLoaded`; built-in per-stage timeouts, a
wall-clock watchdog (default 120s, `TDESKTOP_TEST_WATCHDOG` override), and guaranteed
`TEST_COMPLETE` + quit on every exit path including timeout.
- `test_log.h` — evidence dir from `TDESKTOP_TEST_EVIDENCE_DIR` (the workspace `test-run`
helper sets it), flushed absolute-path logging, `Step/Pass/Fail/Check/Note`, `CheckNear`
tolerance assertions, `LogGeometry`, the standard markers.
- `test_widgets.h``FindAll<T>`/`FindFirst<T>`/`FindVisible<T>` (the `dynamic_cast`-based
finders that avoid the guaranteed `findChildren<CustomWidget*>` crash), `Click`, `TypeText`,
`PressKey` via real Qt events.
- `test_capture.h``CaptureWidget`/`CaptureRect` (visibility check, `QWidget::grab()` so
floating elements and locked desktops cannot occlude, automatic blank-image FAIL, geometry
log, `SCREENSHOT` marker), `Crop`/`Zoom`/`ContactSheet` for tight same-scale evidence.
- `test_agent.h``Test::Fire(name)` / `HasFired(name)` named waitpoints;
`launch_finished` fires at the end of `Application::run()`. `TDESKTOP_TEST_SCALE` is applied
by the harness at startup.
- `test_scenario.cpp` — the overlay-owned slot: it defines `Test::SetupScenario(runner)` and
is a no-op in the repository.
A minimal scenario shape:
```cpp
void SetupScenario(not_null<Runner*> runner) {
runner->waitEvent(u"launch_finished"_q);
runner->waitForChatsLoaded();
runner->add({
.name = u"open the target and verify the row"_q,
.run = [] { /* trigger the flow under test */ },
.until = [] {
return Test::FindFirst<Ui::SomeWidget>(
Core::App().activeWindow()->widget()) != nullptr;
},
.then = [] {
const auto row = Test::FindFirst<Ui::SomeWidget>(
Core::App().activeWindow()->widget());
Test::LogGeometry(u"row"_q, row->geometry());
Test::CheckNear(row->height(), st::someRowHeight, 1, u"row height"_q);
Test::CaptureWidget(row, u"target_row"_q);
},
});
}
```
**The overlay = replacing `test_scenario.cpp` with the task's scenario.** Author the scenario
fresh against the CURRENT implementation from the task's check design; never re-implement
logging, finding, capturing, watchdogs, or quit handling that the harness already provides —
re-derived scaffolding is where capture flaws come from. Only two kinds of edits may touch
other files, and both stay in the inventory: a one-line `Test::Fire("task_waitpoint")` in code
this task already owns, and a true in-situ injection at the highest level that still exercises
the change (often a direct data-layer call like `item->applyEdition(...)` rather than a faked
MTP response). The scenario is the complete runtime driver of first resort: prefer
programmatically triggering every required action and judging the saved logs and captures
afterwards over any external desktop driver, whether or not one is available. Drive the whole
task-specific flow inside the Debug binary on the event loop, waiting for observable state,
logging assertions, capturing the rendered target in-process, and quitting. A locked macOS
session does not reduce required coverage and is never a testing blocker. The scenario runs
only when `-testagent` was passed AND the live portable folder carries the `testing` marker,
so it can never run against real account data. The overlay must:
- Keep any code added outside `test/` inside `#ifdef _DEBUG` blocks only when it would
change release behavior; harness calls like `Test::Fire` are runtime no-ops and need no
guard.
- Pick a **test strategy** and record it in the spec:
`live-data` (use real account data) · `live-mutate` (really create an entity — prefer a
throwaway target, clean up after) · `inject` (build fake local state without the network) ·
`mock-api` (intercept specific requests, return canned responses — for payments/destructive).
Prefer `inject` over `live-mutate` to avoid account/server accumulation and flake.
- Drive the scenario on the Qt event loop, preferring **condition-waits over fixed timers**
(wait until the target widget/data actually exists, with a timeout fallback). Fixed sleeps are
the main source of screenshot flake.
- Write a flushed log to `<EVIDENCE_DIR>/test_log.txt` (open Append|Text, flush after each write) and
save screenshots to `<EVIDENCE_DIR>/screenshots/`. Delete the old log at the first step.
- **Capture the target tightly.** Grab the specific widget / row / glyph (or crop the saved PNG to
it) so the target is unambiguously in frame at usable resolution. A full-window grab that leaves
the target clipped, off-screen, or thumbnail-sized is NOT acceptable evidence — if the target
isn't clearly captured, that is a TEST_FLAW (re-frame), never a pass.
- When the desktop is locked or an OS screenshot is unavailable, capture from inside the process
with `QWidget::grab()` or a renderer-owned image after layout and paint have completed. A widget or
test-window grab plus logged geometry is primary visual evidence; never wait for unlock merely to
obtain a desktop screenshot.
- Express the flow as `Runner` stages with **condition-waits over fixed timers** (an `until`
predicate on the target widget/data actually existing, with the stage timeout as fallback).
Fixed sleeps are the main source of screenshot flake.
- Log through `test_log.h` (`Step`/`Pass`/`Fail`/`Check`/`Note`/`CheckNear`/`LogGeometry`) —
it already writes the flushed absolute-path log and the exact `TEST_STEP` / `TEST_RESULT` /
`SCREENSHOT` / `TEST_COMPLETE` markers the external runner parses. Never hand-roll marker
strings or log files.
- **Capture the target tightly** with `CaptureWidget`/`CaptureRect` — the specific widget /
row / glyph, unambiguously in frame at usable resolution. A full-window grab that leaves the
target clipped, off-screen, or thumbnail-sized is NOT acceptable evidence — if the target
isn't clearly captured, that is a TEST_FLAW (re-frame), never a pass. The helpers grab
in-process after layout and paint, so a locked desktop never blocks capture and a blank
grab fails loudly instead of passing silently.
- **Lay down the oracle's references.** Save every applicable independent reference beside the
crop. Exact asset work saves OLD and intended-NEW art as `<name>_{old,new}.png`. Without target
artwork, save the baseline/reference-component crop when available and log the contract anchors,
crop (`SaveImage`, `ContactSheet` for same-scale comparison). Exact asset work saves OLD and
intended-NEW art as `<name>_{old,new}.png`. Without target artwork, save the
baseline/reference-component crop when available and log the contract anchors,
style/resource identities, and measurements. Never fabricate an `_new` image.
- Emit these markers, one per line:
`TEST_STEP: <desc>` · `TEST_RESULT: PASS: <what>` / `TEST_RESULT: FAIL: <what> - <details>` ·
`SCREENSHOT: <full path>` · `TEST_COMPLETE` (immediately before quit).
- Prefer asserting on **logged state** (log the actual value, assert on text — deterministic);
reserve screenshots for genuinely visual checks where an eye is the right judge.
- **Watchdog:** install a `QTimer` at scenario start that force-quits (`Core::Quit()`, and if
needed `std::abort` after a flush) at a hard wall-clock cap (default 120s). This guarantees the
app never hangs holding a lock on the exe — independent of the runner's own timeout.
- End every path (success or assertion failure) by logging `TEST_COMPLETE` then `Core::Quit()`.
- Rely on the Runner's built-in watchdog and termination: it force-quits at the wall-clock cap
and ends every path — success, assertion failure, or stage timeout — with `TEST_COMPLETE`
then quit, so the app never hangs holding a lock on the exe. Do not install a second
watchdog and do not call `Core::Quit()` from scenario code.
### Finding widgets in an overlay (CRITICAL — avoids a guaranteed crash)
@@ -316,40 +373,34 @@ do **NOT** declare `Q_OBJECT` — they have no own meta-object. So `QObject::fin
get a raw SIGSEGV — the debugger shows `this` with the *wrong* dynamic type. A clean rebuild does NOT
fix it; it is a real bug in the overlay, not a stale build.
- **Never** `findChildren<Ui::SomeCustomWidget*>()`. Instead enumerate `findChildren<QWidget*>()`
(`QWidget` *is* `Q_OBJECT`, so that call is sound and returns all descendants) and
`dynamic_cast<Ui::SomeCustomWidget*>()` each, keeping the non-null results — C++ RTTI identifies the
real type regardless of `Q_OBJECT`. A reusable helper:
```cpp
template <typename T>
[[nodiscard]] std::vector<T*> FindWidgets(QWidget *root) {
auto out = std::vector<T*>();
for (const auto w : root->findChildren<QWidget*>()) {
if (const auto t = dynamic_cast<T*>(w)) out.push_back(t);
}
return out;
}
```
- **Never** `findChildren<Ui::SomeCustomWidget*>()`. Use the harness finders
`Test::FindAll<T>` / `Test::FindFirst<T>` / `Test::FindVisible<T>` from `test_widgets.h`,
which enumerate `findChildren<QWidget*>()` (`QWidget` *is* `Q_OBJECT`, so that call is sound)
and `dynamic_cast` each result — C++ RTTI identifies the real type regardless of `Q_OBJECT`.
- Only genuine Qt `Q_OBJECT` types (`QWidget`, `QLabel`, `QLineEdit`, …) are safe to pass directly to
`findChildren<T*>()`.
### Log to an ABSOLUTE path (the launcher chdir's)
The Windows launcher changes the working directory to the exe folder before the app runs, so a
**relative** overlay log path (`<EVIDENCE_DIR>/test_log.txt`) silently fails to write (`QFile` won't
create missing parents) — the run looks "clean" but produces no evidence. Create and resolve
`EVIDENCE_DIR` to an absolute path up front (or bake its absolute path into the overlay) so flushes
actually land; likewise for screenshots.
**relative** log path silently fails to write (`QFile` won't create missing parents) — the run
looks "clean" but produces no evidence. `Test::EvidenceDir()` resolves
`TDESKTOP_TEST_EVIDENCE_DIR` (which the workspace `test-run` helper always exports as an
absolute path) and creates it up front, so harness logging and captures are immune; never
bypass it with hand-built relative paths.
### Git mechanics for the overlay (no stash)
- Before authoring, inventory every tracked overlay path in `<WORK_DIR>/test-overlay.paths`; no
unrelated or untracked source path may be used. After building, save the overlay with
`git diff --binary HEAD > <WORK_DIR>/test-overlay.patch` and verify the patch is nonempty and
reapplicable. Restore only the inventoried overlay paths to `GREEN_REF`; never hard-reset the
repository. The overlay never enters an impl commit.
- Next round, re-apply on top of the new implementation: `git apply --3way
<WORK_DIR>/test-overlay.patch`. This succeeds ~90% of the time when the tail change was small.
- The inventory in `<WORK_DIR>/test-overlay.paths` is normally exactly
`Telegram/SourceFiles/test/test_scenario.cpp`, plus any in-situ injection or `Test::Fire`
paths; no unrelated or untracked source path may be used. After building, save the overlay
with the workspace helper's `overlay-save` command: it verifies every dirty path against the
inventory, writes a nonempty verified `<WORK_DIR>/test-overlay.patch`, and restores only the
inventoried overlay paths to the wrapper's restore ref; never hard-reset the repository. The
overlay never enters an impl commit.
- Next round, re-apply on top of the new implementation with `overlay-apply` (a `--3way`
application that reports conflicted paths). This succeeds ~90% of the time when the tail change
was small.
- On conflict, **re-author the conflicting hunk from the latest Attempt/Run in `<WORK_DIR>/test.md`** (which
records injection point, fake values, and assertions) rather than fighting conflict markers.
Scenario steps that only call public APIs should live in their own block so they never conflict;
@@ -369,22 +420,18 @@ actually land; likewise for screenshots.
it and the binary keeps the OLD asset. Before building such a task force regeneration — touch the
referencing `.style` (or clean the codegen output) — so the change actually ships. A render that
shows no difference from before is the symptom of skipping this.
- Run: run the SETUP steps (Test account) -> create `EVIDENCE_DIR` -> launch `EXE` **with
`-testagent`** in the background, redirecting stdout to `<EVIDENCE_DIR>/app_stdout.txt` and stderr
to `<EVIDENCE_DIR>/app_stderr.txt` (this flag prevents modal crash hangs, and stderr captures
assertion text) -> **start a hard wall-clock deadline (~90s) from launch** -> poll
`<EVIDENCE_DIR>/test_log.txt` every ~5s -> on each `SCREENSHOT:` read the image and judge it -> detect
`TEST_COMPLETE` (success) or process death (crash) or no new output for the watchdog cap, or the
hard deadline elapsing (hang) -> path-scoped kill of any straggler (Test account → "Serialize app
runs") -> save the binary overlay patch -> restore only inventoried overlay
paths to `GREEN_REF` (the patch must be saved before this restore).
On Windows, launch and capture both streams like:
$exe = (Resolve-Path "$EXE").Path
Start-Process -FilePath $exe -ArgumentList '-testagent' `
-RedirectStandardError "$EVIDENCE_DIR/app_stderr.txt" `
-RedirectStandardOutput "$EVIDENCE_DIR/app_stdout.txt" -PassThru
- Run: execute the workspace helper's `test-run` command with `EXE` and `EVIDENCE_DIR`. One call
performs the SETUP steps (Test account), creates `EVIDENCE_DIR`, path-scope-kills stragglers,
launches `EXE` **with `-testagent -noupdate`** (so a shipped update can never replace the
binary under test mid-run) capturing stdout to `<EVIDENCE_DIR>/app_stdout.txt` and
stderr to `<EVIDENCE_DIR>/app_stderr.txt` (the flag prevents modal crash hangs, and stderr
captures assertion text), enforces **a hard wall-clock deadline from launch** and a quiet-log
watchdog while polling `<EVIDENCE_DIR>/test_log.txt`, detects `TEST_COMPLETE` (success) versus
process death (crash) versus the caps elapsing (hang), kills any straggler, and returns one JSON
report with the parsed markers, stderr tail, and fresh crash diagnostics. Then read each
`SCREENSHOT:` image and judge it, save the binary overlay patch, and restore only inventoried
overlay paths (`overlay-save` — the patch must be saved before that restore). The runner only
gathers evidence; ASSESS below stays the agent's own adversarial judgement.
### Crashes & assertions (always launch the test binary with `-testagent`)
@@ -477,9 +524,8 @@ The on-disk `EXE` (`out/Debug/Telegram.exe`) always contains the compiled overla
Restoring source does not rewrite the binary. When the loop reaches a TERMINAL verdict (APPROVED,
BLOCKED, UNRECOVERABLE, or attempt cap), after the final path-scoped kill and exact-path source
restore, **delete the built `EXE`** so no overlay-laden test binary is left for
the user to launch by mistake:
Remove-Item -Force "$EXE"
the user to launch by mistake — the workspace helper's `test-cleanup --exe EXE --delete-exe` does
the final path-scoped kill and the deletion in one call.
A clean, feature-ready binary is one `BUILD` away on demand. (Delete only on terminal exit — between
attempts the next round rebuilds the overlay, so the binary is reused there.)

View File

@@ -4,42 +4,60 @@
- [Orchestration rules](#orchestration-rules)
- [Completion checks](#artifact-based-completion-checks)
- [Context](#phase-1-context)
- [Plan and assessment](#phase-2-plan)
- [Context and plan](#phase-1-context-and-plan)
- [Assessment](#phase-3-plan-assessment)
- [Implementation and build](#phase-4-implementation)
- [Review](#phase-6-code-review-loop)
- [Windows normalization](#phase-7-native-windows-text-normalization)
- [Prompt delivery](#prompt-delivery-and-logs)
Use these templates as Codex subagent messages. Use them as same-session
checklists only for intentional current-session build work, Phase 7, or when
delegation is unavailable from the start at the current agent depth. Replace
Use these templates as subagent messages on any host. Use them as same-session
checklists only for intentional current-session build work, Phase 7, the
small-task fast path, or when delegation is unavailable from the start at the
current agent depth. Replace
every applicable placeholder: `<TASK>`, `<TASK_ID>`, `<WORK_DIR>`,
`<PROJECT_FILE>`, `<PREVIOUS_CONTEXT>`, `<BUILD>`, `<N>`,
`<OWNED_WRITE_SET>`, `<R>`, `<R-1>`, and `<phase-name>`.
## Orchestration Rules
- When delegation is available, use a fresh subagent for Phase 1, Phase 2, Phase 3, each Phase 4 implementation unit, each Phase 6a lens, each Phase 6s synthesis, and each Phase 6b fix. Do not switch those phases to same-session midstream because of a timeout or missing artifact.
- The Phase 6a lenses of one iteration are independent and write disjoint report files, so spawn them together when capacity allows. Never let one lens read another's report, and never collapse them into a single combined reviewer: their independence is the point of the phase.
- When delegation is available, use a fresh subagent for Phase 1 (context and plan), Phase 3, each Phase 4 implementation unit, each Phase 6a lens, the Phase 6d test-design leaf, each Phase 6s synthesis, and each Phase 6b fix. Do not switch those phases to same-session midstream because of a timeout or missing artifact.
- The Phase 6a lenses of one iteration are independent and write disjoint report files, so spawn them together when capacity allows. The Phase 6d test-design leaf writes its own disjoint artifact and joins the iteration-1 fan-out. Never let one lens read another's report, and never collapse them into a single combined reviewer: their independence is the point of the phase.
- Treat delegation as selected only after the first real phase spawn succeeds; tool presence is insufficient. An immediate depth/capacity/policy rejection before phase work selects same-session checklists and is not a delegated retry.
- Phase 7 runs in the current session on native, non-WSL Windows because it depends on the final local diff and touched-file set. Skip it on WSL and keep files LF/no-BOM there.
- Write each phase prompt to `<WORK_DIR>/logs/phase-<phase-name>.prompt.md` before execution.
- If you delegate a phase, send the prompt file contents as the initial `spawn_agent` message.
- If you delegate a phase, send the prompt file contents as the initial subagent message.
- When writing the phase prompt file, append the standard progress file contract and the standard compact reply block below so the subagent knows how to surface progress before the final artifact.
- After each phase completes, write `<WORK_DIR>/logs/phase-<phase-name>.result.md` with exact
`STATUS:`, `ARTIFACTS:`, `TOUCHED:`, `BLOCKER:`, and `NOTES:` fields.
- Use `fork_turns: "none"` by default. If the phase depends on thread-only context or UI attachments, pass it explicitly or use the smallest positive turn fork needed.
- Use only fields the current `spawn_agent` schema exposes; do not invent role, model, or reasoning arguments. Inherit the parent model/reasoning selection, or match it if the host explicitly supports overrides.
- Give each phase a unique lowercase/digit/underscore task name, store the canonical target returned by `spawn_agent`, and tell the phase it is a leaf that must not delegate.
- Use only fields the current spawn schema exposes; do not invent role, model, or reasoning arguments. Inherit the parent model/reasoning selection, or match it if the host explicitly supports overrides.
- Give each phase a unique lowercase/digit/underscore task name and tell the phase it is a leaf that must not delegate.
- For Phase 1, Phase 3, Phase 4, and Phase 6, if delegated retries still fail, stop and ask the user rather than rerunning the phase locally.
- Never use `codex exec`, background shell child processes, or JSONL child-session logging from this skill.
### Claude Code: synchronous delegation
- Run each leaf as one synchronous foreground Agent call. The call returning
is the completion signal; there is no polling, no heartbeat-mtime ladder,
and no stall windows. On return, validate the artifact-based completion
checks below before treating the phase as done.
- Spawn the independent leaves of one step — the Phase 6a lenses plus the
iteration-1 Phase 6d test-design leaf, or assessed-disjoint Phase 4 units —
as parallel Agent calls in a single message so they run concurrently.
- If a returned leaf fails its completion check, retry that disposable phase
once in a fresh Agent with more specific instructions before stopping to
ask the user.
### Codex: asynchronous spawn and wait
- Store the canonical target returned by `spawn_agent`.
- Poll with `wait_agent` for at most 60 seconds per call; use elapsed wall-clock windows for stall decisions. Use 30-60 second polls when a phase appears close to landing.
- `wait_agent` is mailbox-wide and may wake for another agent or user input. A timeout is not failure. After every wake, handle new user input if any, inspect the saved target with `list_agents`, and check the expected artifact and matching progress file.
- If the expected artifact exists and shows progress, wait again.
- If the expected artifact is not ready but the progress file mtime moved or its heartbeat counter increased since the previous check, wait again. Prefer mtime checks first and avoid rereading the file unless you need detail. Do not count that as a failed wait.
- If neither the expected artifact nor progress file moved for a full five-minute blocked-check window, use `send_message` while the target is running or `followup_task` when it is idle, asking it to refresh progress, finish the artifact, and return the compact block.
- If a second five-minute window after that follow-up still produces no usable artifact or movement, use `interrupt_agent` if needed, confirm the turn stopped, and retry the disposable phase once with a new unique name. There is no close-agent operation.
- For Phase 1, Phase 2, Phase 3, Phase 4, and Phase 6, if delegated retries still fail, stop and ask the user rather than rerunning the phase locally.
- Never use `codex exec`, background shell child processes, or JSONL child-session logging from this skill.
## Standard Progress File Contract
@@ -80,28 +98,51 @@ Do not restate the full context, plan, diff, or long reasoning in the chat reply
## Artifact-Based Completion Checks
- Phase 1 is complete only when `context.md` exists and is non-empty. For a
project task, `project.proposed.md` must also exist and be non-empty.
- Phase 2 is complete only when `plan.md` exists, contains a `## Status` section, and no unintended source edits were made.
- Phase 1 is complete only when `context.md` exists and is non-empty, `plan.md`
exists and contains a `## Status` section, and no unintended source edits
were made. For a project task, `project.proposed.md` must also exist and be
non-empty. For a `Visual: layout` task, `visual.md` must also satisfy the
visual design completion check below.
- Phase 3 is complete only when `plan.md` contains both `Phases:` in the Status section and `Assessed: yes`.
- Phase 4 is complete only when the target phase checkbox changed to checked and the touched-file list matches the owned write set, or the blocker explains any mismatch.
- Phase 5 is complete only when the build outcome is known and the build checkbox is updated on success.
- Phase 6a is complete only when every lens scheduled for iteration `R` wrote `review<R>-<lens>.md` with a `## Verdict:` line and a non-empty `## Checked` section. A lens report that records no checked surfaces is incomplete work: rerun that lens rather than accepting it.
- Phase 6s is complete only when `review<R>.md` exists with a `## Verdict:` line, a non-empty `## Coverage` section, and a `## Dropped` section.
- Phase 6b is complete only when the requested fixes were applied and the post-fix build outcome is known.
- Phase 6d is complete only when `test-design.md` exists, covers every surface
the task's Observable result names (or marks one N/A with a reason), states
a falsifiable oracle with its source for each check, and compresses the run
plan to the fewest possible runs. It must not contain overlay code or filled
Actual/Result fields.
- A perform-task visual design phase is complete only when `visual.md` cites its available
design sources (images when supplied; otherwise request facts and repository/baseline anchors),
records assumptions, and contains desktop anchors, an ordered derivation, tolerances, and
falsifiable geometry checks. Missing mockups alone never make the phase incomplete.
## Phase 1: Context
## Phase 1: Context and Plan
One leaf gathers context and writes the implementation plan in the same
session: the agent that just read every relevant file is the best-informed
planner, and Phase 3 still verifies both artifacts independently. For a
`Visual: layout` task, insert the pipeline's visual design instructions
between the context and plan steps so the leaf writes `visual.md` before
`plan.md` and the plan consumes the derived contract.
Small-task fast path: the performer may run this phase as a same-session
checklist instead of a leaf, but only when the task spec itself names every
file to touch and the change is mechanical — roughly two source files or
fewer, no new APIs, strings, or style tokens, no layout derivation. When in
doubt, delegate. Phase 3 always runs as a fresh leaf and must reject the fast
path (`Fast-Path: rejected` under Status, no `Assessed: yes`) when the task
turns out larger than those criteria; the performer then reruns Phase 1 as a
proper leaf.
```text
You are a context-gathering agent for a large C++ codebase (Telegram Desktop).
You are a context-gathering and planning agent for a large C++ codebase (Telegram Desktop).
TASK: <TASK>
YOUR JOB: Read AGENTS.md, inspect the codebase, find all files and code relevant to this task, and write self-contained implementation context.
YOUR JOB: Read AGENTS.md, inspect the codebase, find all files and code relevant to this task, write self-contained implementation context, and then write a detailed implementation plan.
Steps:
1. Read AGENTS.md for project conventions and build instructions.
@@ -145,74 +186,7 @@ This is the primary task-specific implementation context. All downstream phases
Be extremely thorough. Another agent with no prior context will rely on this file.
Do not implement code in this phase.
```
## Phase 1F: Context for an existing project
```text
You are a context-gathering agent for a follow-up task on an existing project in a large C++ codebase (Telegram Desktop).
NEW TASK: <TASK>
YOUR JOB: Read the existing project state, gather any additional context needed, and produce fresh documents for the new task.
Steps:
1. Read AGENTS.md for project conventions and build instructions.
2. Read <PROJECT_FILE>. This is the project-level blueprint describing everything done so far.
3. Read <PREVIOUS_CONTEXT>. This is the previous task's gathered context.
4. Understand what has already been implemented by reading the actual source files referenced in the project file and previous context.
5. Based on the new task description, search the codebase for any additional files, classes, functions, and patterns that are relevant to the new task but not already covered.
6. Read all newly relevant files thoroughly.
Write two files.
File 1: `<WORK_DIR>/project.proposed.md`
Write a single coherent proposed project document that describes everything,
including this task's changes, as fully implemented and working. Do not modify
`<PROJECT_FILE>` during this phase.
It should incorporate:
- everything from the existing project document that is still accurate and relevant
- the new task's functionality described as part of the project, not as a pending change
- any changed design decisions or architectural updates from the new task requirements
It should not contain:
- temporal state such as "Current State", "Pending Changes", or "TODO"
- history of how requirements changed between tasks
- references to "the old approach" versus "the new approach"
- task-by-task changelog or timeline
- information that contradicts the new task requirements
File 2: `<WORK_DIR>/context.md`
This is the primary document for the new task. It must be self-contained and should include:
- Task Description: The new task restated clearly, with enough project background that an implementation agent can understand it without reading other AI task files
- Relevant Files: Every file path with line ranges relevant to this task
- Key Code Patterns: How similar things are done in the codebase
- Data Structures: Relevant types, structs, classes
- API Methods: Any TL schema methods involved
- UI Styles: Any relevant style definitions
- Localization: Any relevant string keys
- Build Info: Build command and any special notes
- Reference Implementations: Similar features that can serve as templates
Be extremely thorough. Another agent with no prior context should be able to work from this file alone.
Do not implement code in this phase.
```
## Phase 2: Plan
```text
You are a planning agent. You must create a detailed implementation plan.
Read these files:
- <WORK_DIR>/context.md
- Then read the specific source files referenced in context.md to understand the code deeply.
Create a detailed plan in: <WORK_DIR>/plan.md
After context.md is written, create a detailed plan in: <WORK_DIR>/plan.md
The plan.md should contain:
@@ -258,6 +232,68 @@ Number every step. Group steps into phases if there are more than about eight st
Do not implement code in this phase.
```
## Phase 1F: Context and plan for an existing project
```text
You are a context-gathering and planning agent for a follow-up task on an existing project in a large C++ codebase (Telegram Desktop).
NEW TASK: <TASK>
YOUR JOB: Read the existing project state, gather any additional context needed, produce fresh documents for the new task, and then write a detailed implementation plan.
Steps:
1. Read AGENTS.md for project conventions and build instructions.
2. Read <PROJECT_FILE>. This is the project-level blueprint describing everything done so far.
3. Read <PREVIOUS_CONTEXT>. This is the previous task's gathered context.
4. Understand what has already been implemented by reading the actual source files referenced in the project file and previous context.
5. Based on the new task description, search the codebase for any additional files, classes, functions, and patterns that are relevant to the new task but not already covered.
6. Read all newly relevant files thoroughly.
Write two files.
File 1: `<WORK_DIR>/project.proposed.md`
Write a single coherent proposed project document that describes everything,
including this task's changes, as fully implemented and working. Do not modify
`<PROJECT_FILE>` during this phase.
It should incorporate:
- everything from the existing project document that is still accurate and relevant
- the new task's functionality described as part of the project, not as a pending change
- any changed design decisions or architectural updates from the new task requirements
It should not contain:
- temporal state such as "Current State", "Pending Changes", or "TODO"
- history of how requirements changed between tasks
- references to "the old approach" versus "the new approach"
- task-by-task changelog or timeline
- information that contradicts the new task requirements
File 2: `<WORK_DIR>/context.md`
This is the primary document for the new task. It must be self-contained and should include:
- Task Description: The new task restated clearly, with enough project background that an implementation agent can understand it without reading other AI task files
- Relevant Files: Every file path with line ranges relevant to this task
- Key Code Patterns: How similar things are done in the codebase
- Data Structures: Relevant types, structs, classes
- API Methods: Any TL schema methods involved
- UI Styles: Any relevant style definitions
- Localization: Any relevant string keys
- Build Info: Build command and any special notes
- Reference Implementations: Similar features that can serve as templates
Be extremely thorough. Another agent with no prior context should be able to work from this file alone.
File 3: `<WORK_DIR>/plan.md`
After the two documents are written, create a detailed plan with the same
structure required by Phase 1: Task, Approach, Files to Modify, Files to
Create, numbered Implementation Steps grouped into phases when there are more
than about eight steps, Build Verification, and the Status checkbox section.
Do not implement code in this phase.
```
## Phase 3: Plan Assessment
```text
@@ -266,6 +302,7 @@ You are a plan assessment agent. Review and refine an implementation plan.
Read these files:
- <WORK_DIR>/context.md
- <WORK_DIR>/plan.md
- <WORK_DIR>/visual.md when it exists
- Then read the actual source files referenced to verify the plan makes sense.
Assess the plan:
@@ -275,6 +312,17 @@ Assess the plan:
3. Code quality: Will the plan minimize code duplication? Does it follow existing codebase patterns from AGENTS.md?
4. Design: Could the approach be improved? Are there better patterns already used in the codebase?
5. Phase sizing: Each phase should be implementable by a single agent in one session. If a phase has more than about 8-10 substantive code changes, split it further.
6. Visual contract (layout tasks): when visual.md exists, verify its anchors
are real (the cited style tokens, fonts, and reference widgets exist),
the ordered derivation is arithmetically consistent, and every quantity the
plan uses comes from the contract rather than an invented number.
7. Fast-path sizing (when the performer wrote context.md and plan.md itself):
confirm the task really matches the fast-path criteria — the spec names
every file to touch, roughly two source files or fewer, no new APIs,
strings, or style tokens, no layout derivation. If it does not, add
`Fast-Path: rejected` to the Status section, do NOT add `Assessed: yes`,
and state what was underestimated; the performer must rerun Phase 1 as a
fresh leaf.
Update plan.md with your refinements. Keep the same structure but:
- fix any inaccuracies
@@ -388,7 +436,9 @@ Review loop:
```text
LOOP:
1. Run the scheduled Phase 6a lenses for iteration R.
R = 1 -> all four lenses.
R = 1 -> all four lenses, plus the Phase 6d test-design leaf in the
same fan-out (it writes test-design.md and takes no part in
the review verdict).
R > 1 -> every lens whose finding survived synthesis in iteration R-1,
plus `lifetime` unconditionally. A fix pass is the most likely
moment for a new ownership or lifetime error to be introduced,
@@ -557,6 +607,53 @@ cleared alongside them. For the reuse lens, include the searches you ran.>
Reply in the compact block. Do not restate the diff or the report body in chat.
```
### Step 6d: Test-check design (iteration 1 only)
The checks a test must make derive from the task spec and the plan, not from
the last review fix — so their design does not have to wait for the review
loop to finish. Spawn this leaf together with the iteration-1 lenses. It
writes `test-design.md` only; the later test author owns `test.md` and the
overlay, and MUST reconcile every drafted check against the final retained
diff before authoring code — a review fix can change what a check must
observe, and an unreconciled draft is a TEST_FLAW waiting to happen.
```text
You are the test-check designer for one Telegram Desktop task. You design
falsifiable checks. You do not write overlay code, do not run anything, and
do not modify source files.
Read these files:
- the task spec at <TASK_DIR>/task.md and every referenced input image
- <WORK_DIR>/context.md
- <WORK_DIR>/plan.md
- <WORK_DIR>/visual.md when it exists
- .agents/shared/test-loop.md — the sections "Design the tests from THIS
task", "Visual contract", and "Test report"
- the harness headers under Telegram/SourceFiles/test/ (test_runner.h,
test_widgets.h, test_capture.h, test_log.h) — design checks that the
harness's stage waits, typed finders, tight captures, and geometry logs can
observe directly
Then run `git diff` to see the current uncommitted task changes.
Write <WORK_DIR>/test-design.md:
- the chosen test strategy (live-data / live-mutate / inject / mock-api) with
one line of justification
- one `#### Test N — <aspect of THIS change>` block per concrete thing the
diff changed and per surface the task's Observable result names, each with
Expected / Oracle / Oracle source / Observed via fields in the test.md
format, leaving Actual, Screenshots, and Result unfilled
- a surface explicitly marked N/A with a reason when it genuinely cannot be
observed
- a run plan compressed to the fewest possible runs — normally exactly one —
splitting only for checks that cannot share one process lifetime
- a `## Reconcile` line reminding the test author to re-verify every check
against the final retained diff after the review loop
Every check needs an oracle that can come out FAIL. "The screen opened" is
not a check. Do not reuse a generic navigate-and-screenshot scenario.
```
### Step 6s: Review synthesis
```text
@@ -696,7 +793,7 @@ When all phases, including build verification, code review, and Windows line end
## Error Handling
- If any phase fails or gets stuck, follow the timeout and retry rules above. Do not close an agent solely because the final artifact is missing while its progress file is still advancing. For Phase 1, Phase 2, Phase 3, Phase 4, and Phase 6, do not rerun locally after delegated retries fail; ask the user instead.
- If any phase fails or gets stuck, follow the host-specific retry rules above. On Codex, do not close an agent solely because the final artifact is missing while its progress file is still advancing. For Phase 1, Phase 3, Phase 4, and Phase 6, do not rerun locally after delegated retries fail; ask the user instead.
- If `context.md` or `plan.md` is not written properly by a phase, rerun that phase in a fresh subagent with more specific instructions.
- If build errors persist after the build phase's attempts, report the remaining errors to the user.
- If a review-fix phase introduces new build errors that it cannot resolve, report to the user.
@@ -711,17 +808,30 @@ For each phase:
`TOUCHED:`, `BLOCKER:`, and `NOTES:` fields.
For review iterations, include the iteration and the lens in the file name, for example:
- `phase-1-context-plan.prompt.md`
- `phase-6a-review-1-correctness.prompt.md`
- `phase-6a-review-1-correctness.result.md`
- `phase-6a-review-1-lifetime.prompt.md`
- `phase-6a-review-1-reuse.prompt.md`
- `phase-6a-review-1-structure.prompt.md`
- `phase-6d-test-design-1.prompt.md`
- `phase-6d-test-design-1.result.md`
- `phase-6s-synthesis-1.prompt.md`
- `phase-6s-synthesis-1.result.md`
- `phase-6b-fix-1.prompt.md`
- `phase-6b-fix-1.result.md`
## Subagent Pattern
## Subagent Pattern (Claude Code)
1. Write the phase prompt file(s).
2. Make one synchronous foreground Agent call per leaf — parallel calls in a
single message for independent leaves of the same step — with self-contained
prompts.
3. When the calls return, validate the expected artifacts or code changes with
small shell summaries and the completion checks above.
4. Write the result log from the validated outcome and the compact reply block.
## Subagent Pattern (Codex)
Use this pattern conceptually for delegated phases:

View File

@@ -77,14 +77,24 @@ Before planning or editing:
1. Read `SOURCE_ROOT/AGENTS.md`, `REVIEW.md`, `AI_SLOT/AGENTS.md`, `TASK_SPEC`,
every referenced input, and relevant project context.
2. Verify `state.yaml` is `in-progress` and owned by this checkout tag.
3. Require the prepared portable test account. Its absence is a global hard
stop before implementation.
4. Verify a usable Debug executable/build tree, safe path-scoped process
3. Run the scripted preflight report and act on its JSON instead of composing
the equivalent shell checks by hand:
```bash
python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \
source-preflight --source-root SOURCE_ROOT --task TASK_ID --exe EXE
```
It reports source/submodule cleanliness, dirty paths outside the owned
write set, and the golden test account and live marker state for `EXE`.
4. Require the prepared portable test account (`golden_account_present`). Its
absence is a global hard stop before implementation.
5. Verify a usable Debug executable/build tree, safe path-scoped process
control, safe portable-folder operations, and the ability to launch and
render the in-binary test flow. A locked macOS session disables Computer Use
only; it does not fail this preflight or block testing, even when policy was
`required`.
5. For a new run require a clean tracked Telegram worktree, clean submodules,
6. For a new run require a clean tracked Telegram worktree, clean submodules,
and no unrelated untracked files, then initialize local recovery state:
```bash
@@ -97,9 +107,10 @@ Before planning or editing:
and records current `HEAD` in `RUN_REF`. Later task commits may remain above
the retained implementation. Never resolve or record a ref's object name in
an artifact.
6. For an interrupted run, allow dirty Telegram paths only when every one is
7. For an interrupted run, allow dirty Telegram paths only when every one is
listed in `work/owned-paths.txt` and completed phase artifacts prove this
task owns them. Otherwise hard-stop without cleaning them.
task owns them (`dirty_outside_owned` empty in the preflight report).
Otherwise hard-stop without cleaning them.
Do not stash. Do not reset, restore, stage, commit, or delete an unexpected
path. Invocation authorizes recovery only for paths proven to belong to this
@@ -119,6 +130,7 @@ work/review1-lifetime.md
work/review1-reuse.md
work/review1-structure.md
work/review1.md # synthesized review for the iteration
work/test-design.md # check design drafted during review iteration 1
work/test.md
work/result.md
work/owned-paths.txt
@@ -165,16 +177,24 @@ exceptional `Block` commit captures the whole task record.
## Delegation
Use `references/phase-prompts.md` for the exact context, plan, assessment,
implementation, build, review, and native-Windows normalization prompts.
Use `references/phase-prompts.md` for the exact context-and-plan, assessment,
implementation, build, review, test-design, and native-Windows normalization
prompts, plus the host-specific orchestration rules.
- The performer is the only stateful task owner.
- Probe nested mode with the first real leaf phase. If depth, capacity, or
policy rejects that spawn before work begins, execute the same prompt
checklists in the performer. This is a supported mode, not degraded failure.
- In nested mode, use a fresh leaf for context, planning, assessment, each
implementation unit, review, review-fix, and test authoring. Every leaf must
be told not to delegate and never to commit.
- In nested mode, use a fresh leaf for context-and-plan, assessment, each
implementation unit, review lenses, test design, review synthesis,
review-fix, and test authoring. Every leaf must be told not to delegate and
never to commit.
- Small-task fast path: the performer may run the context-and-plan checklist
itself, without a leaf, only when the task spec itself names every file to
touch and the change is mechanical — roughly two source files or fewer, no
new APIs, strings, or style tokens, no layout derivation. When in doubt,
delegate. Assessment always runs as a fresh leaf and has the authority to
reject the fast-path sizing, which forces a proper Phase 1 leaf rerun.
- Use `fork_turns: "none"` with explicit paths. Fork the smallest turn window
only for genuinely unavailable chat-only visual context.
- Inherit the parent's model and reasoning level. Do not invent tool fields.
@@ -183,44 +203,51 @@ implementation, build, review, and native-Windows normalization prompts.
- Never duplicate the performer or an implementation unit with uncertain
writes.
Write the delegated prompt first. Require an early small heartbeat and a final
reply containing only status, artifact paths, touched paths, and blocker.
Poll no longer than 60 seconds. A timeout is not failure. Use artifact mtimes
and heartbeat counters; after five minutes without movement, message the same
target, and after a second unchanged five-minute window interrupt and retry
that disposable phase once. Never replace a live stateful performer.
Write the delegated prompt first. Require a final reply containing only
status, artifact paths, touched paths, and blocker. On Claude Code, run each
leaf as a synchronous foreground call and validate its artifacts when the call
returns; run independent leaves of one step as parallel calls in a single
message. On Codex, use the asynchronous wait ladder from the phase prompts:
poll no longer than 60 seconds, treat a timeout as not-failure, use artifact
mtimes and heartbeat counters, message the target after five minutes without
movement, and interrupt and retry that disposable phase once after a second
unchanged window. On either host, never replace a live stateful performer.
## Implementation phases
Run sequentially:
1. **Context.** Write a self-contained `work/context.md`. For project work,
read the current project file and nearest approved task context, then write
`work/project.proposed.md` as a coherent finished-state blueprint. Use the
Phase 1F prompt when prior task context exists; otherwise use Phase 1 with
the project file. Do not promote the proposal yet; blocked work must not
become project truth.
2. **Visual design.** For `Visual: layout`, write `work/visual.md`. Derive every
dimension from request relationships, supplied images, font metrics, style
tokens, sibling geometry, or a cited desktop analogue. Use ordered
calculations, tolerances, relationship checks, same-scale comparison, and
an adversarial rejection pass. For `Visual: appearance`, keep the lighter
exact color/text/glyph oracle. Skip for non-visual work.
3. **Plan.** Write `work/plan.md` with exact files, functions, ordered steps,
bounded phases, owned write sets, Debug build verification, and status
checkboxes.
4. **Assess.** Independently verify paths and APIs, completeness, design,
duplication, edge cases, repository conventions, and phase sizing. Require
`Phases: <N>` and `Assessed: yes`.
5. **Implement.** Run one leaf per assessed plan phase. Before each edit,
1. **Context, visual design, and plan.** One leaf writes a self-contained
`work/context.md`, then — for `Visual: layout` tasks — `work/visual.md`,
then `work/plan.md` with exact files, functions, ordered steps, bounded
phases, owned write sets, Debug build verification, and status checkboxes.
For project work it also writes `work/project.proposed.md` as a coherent
finished-state blueprint; use the Phase 1F prompt when prior task context
exists, otherwise Phase 1 with the project file. Do not promote the
proposal yet; blocked work must not become project truth.
The visual contract derives every dimension from request relationships,
supplied images, font metrics, style tokens, sibling geometry, or a cited
desktop analogue, with ordered calculations, tolerances, relationship
checks, same-scale comparison, and an adversarial rejection pass. For
`Visual: appearance`, keep the lighter exact color/text/glyph oracle. Skip
the visual step for non-visual work.
Small-task fast path: under the strict criteria in the Delegation section,
the performer may run this phase as a same-session checklist producing the
same artifacts.
2. **Assess.** Independently verify paths and APIs, completeness, design,
duplication, edge cases, repository conventions, and phase sizing; on
layout tasks verify the visual contract's anchors and derivation; on a
fast-path plan verify the sizing itself. Require `Phases: <N>` and
`Assessed: yes`.
3. **Implement.** Run one leaf per assessed plan phase. Before each edit,
update `work/owned-paths.txt`. A leaf edits only its owned paths and its
phase status; it does not commit.
6. **Build.** Run the resolved Debug build in the performer. Fix only build
4. **Build.** Run the resolved Debug build in the performer. Fix only build
errors belonging to the task. If the task changed only a resource consumed
by codegen, force its documented regeneration so the Debug binary contains
the new resource. A file-lock/access-denied build error is an immediate
global hard stop with no retry or workaround.
7. **Review.** Run the multi-lens review/fix loop from the phase prompts for up
5. **Review.** Run the multi-lens review/fix loop from the phase prompts for up
to three review iterations. Each iteration runs four independent lenses over
the task diff — correctness, lifetime and ownership, reuse, structure — and
then one synthesis pass that confirms every finding against the code itself
@@ -228,28 +255,40 @@ Run sequentially:
defaults to not clean and must record the surfaces it checked; an approved
review carries that merged coverage as the evidence for approval. Rebuild
after every fix pass. Give the correctness and structure lenses the visual
contract on layout tasks.
8. **Normalize.** On native non-WSL Windows, normalize only task-owned source,
contract on layout tasks. Alongside the iteration-1 lenses, spawn the
Phase 6d test-design leaf; it drafts `work/test-design.md` from the spec,
plan, and current diff so the test loop does not start from scratch.
6. **Normalize.** On native non-WSL Windows, normalize only task-owned source,
header, style, localization, and build/config text to CRLF without BOM,
preserving content and trailing-newline state, then rebuild. On macOS,
Linux, and WSL preserve LF/no-BOM.
9. **Commit and test.** Create the Telegram implementation commit, then run the
test loop below. An implementation bug creates the next committed attempt;
keep the same `Task:` locator on every attempt. After each clean buildable
attempt, move the local retained-implementation ref with:
7. **Commit and test.** Create the Telegram implementation commit with the
scripted helper, then run the test loop below:
```bash
python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \
source-mark-green --source-root SOURCE_ROOT --task TASK_ID
source-commit --source-root SOURCE_ROOT --task TASK_ID \
--subject "<one concise plain-language subject>" --mark-green
```
It verifies every dirty path against `work/owned-paths.txt` (plus the
optional `tasks/TASK_ID.md` source note), stages exactly those paths,
writes and validates the exact three-line message, and with `--mark-green`
moves the retained-implementation refs — replacing manual staging and the
separate `source-mark-green` call. An implementation bug creates the next
committed attempt through the same helper; keep the same `Task:` locator on
every attempt.
## Telegram commits
The performer owns commit boundaries. Inspect every dirty path, verify it is in
the union of owned write sets, and stage only explicit paths. Never use
`git add -A`. Commit an intended submodule first only when its preflight was
clean and all of its changes belong to this task, then stage the superproject
pointer.
The performer owns commit boundaries. The workspace helper's `source-commit`
command is the standard mechanism: it enforces this section's contract —
every dirty path verified against the union of owned write sets, only explicit
paths staged, never `git add -A`, the exact three-line message — in one
deterministic call. Commit an intended submodule first, manually, only when
its preflight was clean and all of its changes belong to this task, then stage
the superproject pointer; the helper refuses dirty submodule pointers so an
unintended one can never slip into an attempt.
Every implementation or implementation-fix commit message is exactly:
@@ -279,16 +318,38 @@ or operating a UI driver. Retain all task-derived oracle, layout measurement,
overlay, watchdog, crash/assertion, hang, account, attempt, report, and evidence
rules, with these external-task safety adaptations:
- The performer, not leaves, stages and commits every attempt.
- Overlay code may modify only tracked task-owned source paths. Inventory them
in `work/test-overlay.paths`; never introduce an untracked source file.
- Save the overlay with `git diff --binary HEAD > work/test-overlay.patch`,
verify it is nonempty and reapplicable, then restore only inventoried overlay
paths to `RUN_REF`. Do not run a repository-wide hard reset. After an
implementation-fix commit, move both `GREEN_REF` and `RUN_REF` to the new
clean tip before reapplying the overlay.
- Reapply with `git apply --3way`; re-author a conflicting hunk from `test.md`
rather than leaving conflict markers.
- The performer, not leaves, stages and commits every attempt (through
`source-commit`).
- The overlay is authored against the permanent harness in
`Telegram/SourceFiles/test/` and normally consists of replacing
`Telegram/SourceFiles/test/test_scenario.cpp` alone — that slot file is
always a permitted overlay path. Beyond it, overlay code may modify only
tracked task-owned source paths (one-line `Test::Fire` waitpoints or true
in-situ injections). Inventory every overlay path in
`work/test-overlay.paths`; never introduce an untracked source file, and
never re-implement logging, widget-finding, capture, watchdog, or quit
mechanics the harness already provides.
- Save and restore the overlay with the scripted helper instead of manual git
mechanics:
```bash
python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \
overlay-save --source-root SOURCE_ROOT --task TASK_ID --restore run
```
It verifies every dirty path against the inventory, refuses untracked
files, writes a nonempty verified `work/test-overlay.patch`, and restores
only inventoried paths to `RUN_REF` — never a repository-wide hard reset.
After an implementation-fix commit (`source-commit --mark-green` moves both
`GREEN_REF` and `RUN_REF`), reapply with:
```bash
python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \
overlay-apply --source-root SOURCE_ROOT --task TASK_ID
```
It applies with `--3way` and reports conflicted paths; re-author a
conflicting hunk from `test.md` rather than leaving conflict markers.
- On locked macOS, force overlay-only testing without waiting or blocking.
Encode the complete interaction inside the Debug binary using application
actions or Qt events, log assertions and geometry, capture widgets/windows
@@ -305,20 +366,43 @@ rules, with these external-task safety adaptations:
- Set `RUN_DIR` and `EVIDENCE_DIR` to
`TASK_DIR/.local/runs/attempt-<n>/run-<m>/`. Promote only decisive compact
logs/screenshots into tracked `evidence/`.
- Launch every test binary with `-testagent`. Detect crashes from process death
without `TEST_COMPLETE` plus a new `tdata/working`, not exit code. Read
captured stderr first, then `tdata/working`, then note the minidump.
- Before each app run and build, stop only a process whose resolved executable
path equals `EXE`. Never use image-name-wide termination.
- Enforce both the in-app watchdog and an external wall-clock deadline. Count
test runs independently from implementation attempts and stop at
`MAX_TEST_RUNS`.
- Execute every app run through the scripted runner instead of hand-composed
launch/poll/kill shell:
```bash
python3 SOURCE_ROOT/.agents/skills/process-inbox/scripts/workspace.py \
test-run --exe EXE --run-dir RUN_DIR [--env NAME=VALUE ...] \
[--deadline 120] [--quiet 60]
```
One call performs the idempotent portable-account SETUP, the path-scoped
straggler kill, the `-testagent -noupdate` launch (never auto-update a
test binary) with stdout/stderr capture and
`TDESKTOP_TEST_EVIDENCE_DIR` set to `RUN_DIR`, the external wall-clock
deadline and quiet-log watchdog, and returns one JSON report: outcome,
`TEST_COMPLETE` state, parsed `TEST_STEP`/`TEST_RESULT`/`SCREENSHOT`
markers, stderr tail, fresh `tdata/working` crash excerpt, and minidump
paths. The performer then judges the evidence itself — the runner gathers,
it never assesses. Crash detection keys on process death without
`TEST_COMPLETE` plus a fresh `tdata/working`, not exit code.
- If the account breaks mid-loop (login screen, `AUTH_KEY_DUPLICATED`), run
`test-account-reset --exe EXE` — it deletes only a marked live copy and
re-copies golden — then retry once.
- Enforce the in-app watchdog too. Count test runs independently from
implementation attempts and stop at `MAX_TEST_RUNS`.
- Plan the fewest possible runs: one complete programmed scenario per attempt
that proves every check in a single execution, splitting only for checks
that cannot share one process lifetime. `MAX_TEST_RUNS` is a safety cap,
never a budget to spend.
- Delete the overlay-bearing Debug executable on every terminal test exit so
the user cannot launch it accidentally.
- Start the test author from `work/test-design.md` when the review-phase
draft exists; the author still reconciles every drafted check against the
final retained diff before writing overlay code, and owns `test.md`.
- On every terminal test exit, run `test-cleanup --exe EXE --delete-exe` so no
straggler survives and no overlay-bearing Debug executable is left for the
user to launch accidentally.
- When a task needs an out-of-scope fence, snapshot it with
`fence-create --file <baseline> --root SOURCE_ROOT <paths...>` and verify it
before publication with `fence-check`.
The test author must read the full task specification and every current-branch
commit whose message has this task's exact `Task:` line. For an uninterrupted

View File

@@ -8,8 +8,10 @@ import os
from pathlib import Path, PurePosixPath
import re
import shutil
import signal
import subprocess
import sys
import time
TAG_PATTERN = re.compile(r"[a-z0-9][a-z0-9-]*")
@@ -36,6 +38,14 @@ STATE_FIELD_ORDER = [
"phase",
"inbox_receipt",
]
PORTABLE_GOLDEN = "test_TelegramForcePortable"
PORTABLE_LIVE = "TelegramForcePortable"
PORTABLE_REAL = "real_TelegramForcePortable"
PORTABLE_MARKER = "testing"
OVERLAY_PATHS_FILE = "test-overlay.paths"
OVERLAY_PATCH_FILE = "test-overlay.patch"
TEST_LOG_FILE = "test_log.txt"
TEST_COMPLETE_MARKER = "TEST_COMPLETE"
class WorkspaceError(RuntimeError):
@@ -1004,24 +1014,648 @@ def command_source_begin(args):
}, indent=2, sort_keys=True))
def command_source_mark_green(args):
config, _ = task_action_config(args)
def mark_source_green(config, task_id):
source = Path(config["source_root"])
ensure_clean(source, "Telegram source checkout")
base = source_task_ref(args.task, "base")
base = source_task_ref(task_id, "base")
if resolved_ref(source, base) is None:
raise WorkspaceError("The local task baseline ref is missing")
if run_git(source, "merge-base", "--is-ancestor", base, "HEAD", check=False).returncode:
raise WorkspaceError("The retained implementation does not descend from the task baseline")
validate_task_commit(source, "HEAD", args.task)
run_git(source, "update-ref", source_task_ref(args.task, "green"), "HEAD")
run_git(source, "update-ref", source_task_ref(args.task, "run"), "HEAD")
validate_task_commit(source, "HEAD", task_id)
run_git(source, "update-ref", source_task_ref(task_id, "green"), "HEAD")
run_git(source, "update-ref", source_task_ref(task_id, "run"), "HEAD")
def command_source_mark_green(args):
config, _ = task_action_config(args)
mark_source_green(config, args.task)
print(json.dumps({
"task": args.task,
"source_state": "retained",
}, indent=2, sort_keys=True))
def run_git_binary(path, *args):
result = subprocess.run(
["git", "-C", str(path), *args],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
if result.returncode:
raise WorkspaceError(
result.stderr.decode("utf-8", "replace").strip()
or "git failed"
)
return result.stdout
def resolved_exe(value):
path = Path(value).expanduser().resolve()
if not path.is_file():
raise WorkspaceError(f"Test executable does not exist: {path}")
return path
def portable_root_for(exe, override):
if override:
root = Path(override).expanduser().resolve()
if not root.is_dir():
raise WorkspaceError(f"Portable root does not exist: {root}")
return root
for parent in exe.parents:
if parent.suffix == ".app":
return parent.parent
return exe.parent
def setup_test_account(root):
golden = root / PORTABLE_GOLDEN
live = root / PORTABLE_LIVE
real = root / PORTABLE_REAL
if not golden.is_dir():
raise WorkspaceError(f"Missing golden test account: {golden}")
if (live / PORTABLE_MARKER).exists():
return "reused-marked-live"
if live.exists():
if real.exists():
shutil.rmtree(live)
state = "replaced-manual-live"
else:
live.rename(real)
state = "preserved-real"
else:
state = "fresh-copy"
shutil.copytree(golden, live)
(live / PORTABLE_MARKER).write_text("1\n", encoding="utf-8")
return state
def reset_broken_test_account(root):
live = root / PORTABLE_LIVE
if not (live / PORTABLE_MARKER).exists():
raise WorkspaceError(
f"Refusing to reset an unmarked live folder: {live}"
)
shutil.rmtree(live)
return setup_test_account(root)
def processes_with_executable(exe):
value = str(exe)
pids = []
if sys.platform == "win32":
escaped = value.replace("'", "''")
script = (
"Get-CimInstance Win32_Process | "
f"Where-Object {{ $_.ExecutablePath -eq '{escaped}' }} | "
"ForEach-Object { $_.ProcessId }"
)
result = subprocess.run(
["powershell", "-NoProfile", "-Command", script],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
for line in result.stdout.splitlines():
line = line.strip()
if line.isdigit():
pids.append(int(line))
return pids
result = subprocess.run(
["ps", "-axo", "pid=,comm="],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
for line in result.stdout.splitlines():
parts = line.strip().split(None, 1)
if len(parts) != 2 or not parts[0].isdigit():
continue
pid, comm = int(parts[0]), parts[1]
if comm == value:
pids.append(pid)
continue
if sys.platform.startswith("linux"):
try:
if os.readlink(f"/proc/{pid}/exe") == value:
pids.append(pid)
except OSError:
continue
return pids
def kill_processes_with_executable(exe):
killed = []
for pid in processes_with_executable(exe):
try:
if sys.platform == "win32":
subprocess.run(
["taskkill", "/PID", str(pid), "/F"],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
)
else:
os.kill(pid, signal.SIGKILL)
killed.append(pid)
except (OSError, subprocess.SubprocessError):
continue
return killed
def parse_test_log(text):
steps = []
passed = []
failed = []
screenshots = []
for line in text.splitlines():
if line.startswith("TEST_STEP: "):
steps.append(line[len("TEST_STEP: "):])
elif line.startswith("TEST_RESULT: PASS: "):
passed.append(line[len("TEST_RESULT: PASS: "):])
elif line.startswith("TEST_RESULT: FAIL: "):
failed.append(line[len("TEST_RESULT: FAIL: "):])
elif line.startswith("SCREENSHOT: "):
screenshots.append(line[len("SCREENSHOT: "):])
return {
"steps": steps,
"pass": passed,
"fail": failed,
"screenshots": screenshots,
}
def tail_of_file(path, lines=60):
if not path.is_file():
return None
text = path.read_text(encoding="utf-8", errors="replace")
return "\n".join(text.splitlines()[-lines:]) if text.strip() else None
def parse_env_values(values):
environment = {}
for value in values or ():
if "=" not in value:
raise WorkspaceError(f"Invalid --env value (want NAME=VALUE): {value!r}")
name, content = value.split("=", 1)
if not name:
raise WorkspaceError(f"Invalid --env value (empty name): {value!r}")
environment[name] = content
return environment
def command_test_run(args):
exe = resolved_exe(args.exe)
run_dir = Path(args.run_dir).expanduser().resolve()
run_dir.mkdir(parents=True, exist_ok=True)
(run_dir / "screenshots").mkdir(exist_ok=True)
portable = portable_root_for(exe, args.portable_root)
account = setup_test_account(portable)
stragglers = kill_processes_with_executable(exe)
log_path = run_dir / TEST_LOG_FILE
if log_path.exists():
log_path.unlink()
stdout_path = run_dir / "app_stdout.txt"
stderr_path = run_dir / "app_stderr.txt"
working = portable / PORTABLE_LIVE / "tdata" / "working"
dumps_dir = portable / PORTABLE_LIVE / "tdata" / "dumps"
environment = os.environ.copy()
environment["TDESKTOP_TEST_EVIDENCE_DIR"] = str(run_dir)
environment.update(parse_env_values(args.env))
launched_at = time.time()
with stdout_path.open("wb") as out, stderr_path.open("wb") as err:
process = subprocess.Popen(
[str(exe), "-testagent", "-noupdate"],
stdout=out,
stderr=err,
env=environment,
cwd=str(portable),
)
outcome = None
exit_code = None
complete_seen_at = None
last_size = -1
last_change = launched_at
while True:
time.sleep(0.5)
now = time.time()
size = log_path.stat().st_size if log_path.is_file() else -1
if size != last_size:
last_size = size
last_change = now
complete = False
if size > 0:
complete = TEST_COMPLETE_MARKER in log_path.read_text(
encoding="utf-8", errors="replace"
)
if complete and complete_seen_at is None:
complete_seen_at = now
exit_code = process.poll()
if exit_code is not None:
outcome = "exited"
break
if complete_seen_at is not None and now - complete_seen_at > args.grace:
process.kill()
outcome = "killed-after-complete"
break
if now - launched_at > args.deadline:
process.kill()
outcome = "deadline-killed"
break
if now - last_change > args.quiet and complete_seen_at is None:
process.kill()
outcome = "quiet-killed"
break
process.wait()
ended_at = time.time()
kill_processes_with_executable(exe)
log_text = (
log_path.read_text(encoding="utf-8", errors="replace")
if log_path.is_file()
else ""
)
test_complete = TEST_COMPLETE_MARKER in log_text
crash_report_fresh = (
working.is_file()
and working.stat().st_mtime >= launched_at
and working.stat().st_size > 0
)
dumps = sorted(
str(path) for path in dumps_dir.glob("*.dmp")
if path.stat().st_mtime >= launched_at
) if dumps_dir.is_dir() else []
if outcome == "exited":
if test_complete:
verdict_hint = "complete"
elif crash_report_fresh or dumps:
verdict_hint = "crash"
else:
verdict_hint = "died-without-complete"
elif outcome == "killed-after-complete":
verdict_hint = "complete"
else:
verdict_hint = "hang"
print(json.dumps({
"account": account,
"crash_report": str(working) if working.is_file() else None,
"crash_report_excerpt": (
working.read_text(encoding="utf-8", errors="replace")[:4000]
if crash_report_fresh
else None
),
"crash_report_fresh": crash_report_fresh,
"dumps": dumps,
"duration_seconds": round(ended_at - launched_at, 1),
"exe": str(exe),
"exit_code": exit_code,
"log_path": str(log_path) if log_path.is_file() else None,
"markers": parse_test_log(log_text),
"outcome": outcome,
"portable_root": str(portable),
"run_dir": str(run_dir),
"stderr_tail": tail_of_file(stderr_path),
"stragglers_killed": stragglers,
"test_complete": test_complete,
"verdict_hint": verdict_hint,
}, indent=2, sort_keys=True))
def command_test_cleanup(args):
exe = Path(args.exe).expanduser().resolve()
killed = kill_processes_with_executable(exe)
deleted = False
if args.delete_exe and exe.is_file():
exe.unlink()
deleted = True
print(json.dumps({
"deleted_exe": deleted,
"exe": str(exe),
"killed": killed,
}, indent=2, sort_keys=True))
def command_test_account_reset(args):
exe = resolved_exe(args.exe)
kill_processes_with_executable(exe)
portable = portable_root_for(exe, args.portable_root)
account = reset_broken_test_account(portable)
print(json.dumps({
"account": account,
"portable_root": str(portable),
}, indent=2, sort_keys=True))
def overlay_work_dir(config, slot, task_id):
work = slot / task_relative_dir(task_id) / "work"
if not work.is_dir():
raise WorkspaceError(f"Task work directory does not exist: {work}")
return work
def read_overlay_paths(work):
paths_file = work / OVERLAY_PATHS_FILE
if not paths_file.is_file():
raise WorkspaceError(f"Missing overlay inventory: {paths_file}")
paths = [
line.strip() for line in
paths_file.read_text(encoding="utf-8-sig").splitlines()
if line.strip()
]
if not paths:
raise WorkspaceError(f"Empty overlay inventory: {paths_file}")
return paths
def command_overlay_save(args):
config, slot = task_action_config(args)
source = Path(config["source_root"])
work = overlay_work_dir(config, slot, args.task)
inventory = read_overlay_paths(work)
untracked = run_git(
source, "ls-files", "--others", "--exclude-standard"
).stdout.splitlines()
if untracked:
raise WorkspaceError(
"The overlay may not use untracked source files: "
+ ", ".join(untracked)
)
dirty = changed_paths(source)
outside = [
path for path in dirty
if not path_is_covered(path, inventory)
]
if outside:
raise WorkspaceError(
"Dirty source paths are outside the overlay inventory: "
+ ", ".join(outside)
)
patch = run_git_binary(source, "diff", "--binary", "HEAD")
if not patch.strip():
raise WorkspaceError("The overlay diff is empty; nothing to save")
patch_path = work / OVERLAY_PATCH_FILE
patch_path.write_bytes(patch)
check = subprocess.run(
["git", "-C", str(source), "apply", "--check", "--reverse", str(patch_path)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
if check.returncode:
raise WorkspaceError(
"The saved overlay patch does not verify: "
+ check.stderr.strip()
)
restored = []
if args.restore != "none":
ref = source_task_ref(args.task, args.restore)
if resolved_ref(source, ref) is None:
raise WorkspaceError(f"Missing task ref for restore: {ref}")
run_git(source, "checkout", ref, "--", *inventory)
restored = inventory
remaining = [
path for path in changed_paths(source)
if path_is_covered(path, inventory)
]
if remaining:
raise WorkspaceError(
"Overlay paths remain dirty after restore: "
+ ", ".join(remaining)
)
print(json.dumps({
"patch": str(patch_path),
"patch_bytes": len(patch),
"restored": restored,
"task": args.task,
}, indent=2, sort_keys=True))
def command_overlay_apply(args):
config, slot = task_action_config(args)
source = Path(config["source_root"])
work = overlay_work_dir(config, slot, args.task)
patch_path = work / OVERLAY_PATCH_FILE
if not patch_path.is_file() or not patch_path.stat().st_size:
raise WorkspaceError(f"Missing overlay patch: {patch_path}")
inventory = read_overlay_paths(work)
result = subprocess.run(
["git", "-C", str(source), "apply", "--3way", str(patch_path)],
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
)
conflicts = run_git(
source, "diff", "--name-only", "--diff-filter=U"
).stdout.splitlines()
applied = not result.returncode and not conflicts
outside = [
path for path in changed_paths(source)
if not path_is_covered(path, inventory)
]
print(json.dumps({
"applied": applied,
"conflicts": conflicts,
"error": result.stderr.strip() if result.returncode else None,
"outside_inventory": outside,
"task": args.task,
}, indent=2, sort_keys=True))
def gitlink_paths(source, paths):
result = []
for path in paths:
entry = run_git(source, "ls-files", "-s", "--", path).stdout
if entry.startswith("160000 "):
result.append(path)
return result
def command_source_commit(args):
config, slot = task_action_config(args)
source = Path(config["source_root"])
subject = args.subject.strip()
if not subject or "\n" in subject:
raise WorkspaceError("The commit subject must be a single non-empty line")
if len(subject) > 72:
raise WorkspaceError(
f"The commit subject is too long ({len(subject)} > 72 characters)"
)
work = slot / task_relative_dir(args.task) / "work"
owned_file = work / "owned-paths.txt"
if not owned_file.is_file():
raise WorkspaceError(f"Missing owned-paths inventory: {owned_file}")
owned = [
line.strip() for line in
owned_file.read_text(encoding="utf-8-sig").splitlines()
if line.strip()
]
if not owned:
raise WorkspaceError(f"Empty owned-paths inventory: {owned_file}")
source_note = f"tasks/{args.task}.md"
allowed = owned + [source_note]
dirty = changed_paths(source)
if not dirty:
raise WorkspaceError("The source checkout has no changes to commit")
outside = [
path for path in dirty
if not path_is_covered(path, allowed)
]
if outside:
raise WorkspaceError(
"Dirty source paths are outside the owned write set: "
+ ", ".join(outside)
)
submodules = gitlink_paths(source, dirty)
if submodules:
raise WorkspaceError(
"Submodule pointers must be committed explicitly first: "
+ ", ".join(submodules)
)
for path in dirty:
run_git(source, "add", "--", path)
run_git(
source,
"commit",
"-m",
f"{subject}\n\nTask: {args.task}",
)
validate_task_commit(source, "HEAD", args.task)
if args.mark_green:
mark_source_green(config, args.task)
print(json.dumps({
"committed": dirty,
"marked_green": bool(args.mark_green),
"subject": subject,
"task": args.task,
}, indent=2, sort_keys=True))
def command_source_verify_commit(args):
source = source_root(args.source_root)
validate_task_commit(source, args.ref, args.task)
subject = run_git(
source, "show", "-s", "--format=%s", args.ref
).stdout.strip()
print(json.dumps({
"ref": args.ref,
"subject": subject,
"task": args.task,
"valid": True,
}, indent=2, sort_keys=True))
def file_sha256(path):
digest = hashlib.sha256()
with path.open("rb") as stream:
while True:
chunk = stream.read(1024 * 1024)
if not chunk:
break
digest.update(chunk)
return digest.hexdigest()
def command_fence_create(args):
root = Path(args.root).expanduser().resolve()
if not root.is_dir():
raise WorkspaceError(f"Fence root does not exist: {root}")
if not args.paths:
raise WorkspaceError("No fence paths were provided")
lines = []
for value in args.paths:
path = root / value
if not path.is_file():
raise WorkspaceError(f"Fence path does not exist: {path}")
lines.append(f"{file_sha256(path)} {value}")
target = Path(args.file).expanduser().resolve()
target.parent.mkdir(parents=True, exist_ok=True)
target.write_text("\n".join(lines) + "\n", encoding="utf-8")
print(json.dumps({
"file": str(target),
"paths": len(lines),
}, indent=2, sort_keys=True))
def command_fence_check(args):
root = Path(args.root).expanduser().resolve()
target = Path(args.file).expanduser().resolve()
if not target.is_file():
raise WorkspaceError(f"Fence baseline does not exist: {target}")
mismatched = []
missing = []
checked = 0
for line in target.read_text(encoding="utf-8-sig").splitlines():
line = line.strip()
if not line:
continue
if " " not in line:
raise WorkspaceError(f"Invalid fence line: {line!r}")
expected, value = line.split(" ", 1)
path = root / value
checked += 1
if not path.is_file():
missing.append(value)
elif file_sha256(path) != expected:
mismatched.append(value)
ok = not mismatched and not missing
print(json.dumps({
"checked": checked,
"mismatched": mismatched,
"missing": missing,
"ok": ok,
}, indent=2, sort_keys=True))
if not ok:
sys.exit(2)
def command_source_preflight(args):
config, slot = task_action_config(args)
source = Path(config["source_root"])
dirty = changed_paths(source)
submodule_lines = run_git(
source, "submodule", "status", "--recursive"
).stdout.splitlines()
submodules_dirty = [
line.strip() for line in submodule_lines
if line and line[0] in "+-U"
]
work = slot / task_relative_dir(args.task) / "work"
owned_file = work / "owned-paths.txt"
owned = [
line.strip() for line in
owned_file.read_text(encoding="utf-8-sig").splitlines()
if line.strip()
] if owned_file.is_file() else []
dirty_outside_owned = [
path for path in dirty
if not path_is_covered(path, owned + [f"tasks/{args.task}.md"])
]
result = {
"dirty": dirty,
"dirty_outside_owned": dirty_outside_owned,
"owned_paths_present": owned_file.is_file(),
"source_clean": not dirty,
"submodules_dirty": submodules_dirty,
"task": args.task,
}
if args.exe:
exe = Path(args.exe).expanduser().resolve()
result["exe_present"] = exe.is_file()
if exe.is_file():
portable = portable_root_for(exe, None)
result["golden_account_present"] = (
portable / PORTABLE_GOLDEN
).is_dir()
result["live_marker_present"] = (
portable / PORTABLE_LIVE / PORTABLE_MARKER
).exists()
print(json.dumps(result, indent=2, sort_keys=True))
def command_finish(args):
config, slot = task_action_config(args, allow_project=True)
ensure_clean(Path(config["source_root"]), "Telegram source checkout")
@@ -1474,7 +2108,7 @@ def clear_payload(inbox):
def command_finalize(args):
transaction, metadata = load_transaction(args.transaction)
inbox = Path(metadata["inbox"])
main = Path(metadata["ai_main"])
main = Path(metadata["ai_main"]).resolve()
receipt_value = normalized_publish_path(args.receipt)
if not receipt_value.startswith("receipts/"):
raise WorkspaceError("The tracked receipt must be below receipts/")
@@ -1591,6 +2225,71 @@ def parse_args():
source_mark_green.add_argument("--task", required=True)
source_mark_green.set_defaults(handler=command_source_mark_green)
source_commit = subparsers.add_parser("source-commit")
add_common_arguments(source_commit)
source_commit.add_argument("--task", required=True)
source_commit.add_argument("--subject", required=True)
source_commit.add_argument("--mark-green", action="store_true")
source_commit.set_defaults(handler=command_source_commit)
source_verify_commit = subparsers.add_parser("source-verify-commit")
add_common_arguments(source_verify_commit)
source_verify_commit.add_argument("--task", required=True)
source_verify_commit.add_argument("--ref", default="HEAD")
source_verify_commit.set_defaults(handler=command_source_verify_commit)
source_preflight = subparsers.add_parser("source-preflight")
add_common_arguments(source_preflight)
source_preflight.add_argument("--task", required=True)
source_preflight.add_argument("--exe")
source_preflight.set_defaults(handler=command_source_preflight)
overlay_save = subparsers.add_parser("overlay-save")
add_common_arguments(overlay_save)
overlay_save.add_argument("--task", required=True)
overlay_save.add_argument(
"--restore",
choices=("run", "green", "none"),
default="run",
)
overlay_save.set_defaults(handler=command_overlay_save)
overlay_apply = subparsers.add_parser("overlay-apply")
add_common_arguments(overlay_apply)
overlay_apply.add_argument("--task", required=True)
overlay_apply.set_defaults(handler=command_overlay_apply)
test_run = subparsers.add_parser("test-run")
test_run.add_argument("--exe", required=True)
test_run.add_argument("--run-dir", required=True)
test_run.add_argument("--portable-root")
test_run.add_argument("--deadline", type=float, default=120.0)
test_run.add_argument("--quiet", type=float, default=60.0)
test_run.add_argument("--grace", type=float, default=15.0)
test_run.add_argument("--env", action="append")
test_run.set_defaults(handler=command_test_run)
test_cleanup = subparsers.add_parser("test-cleanup")
test_cleanup.add_argument("--exe", required=True)
test_cleanup.add_argument("--delete-exe", action="store_true")
test_cleanup.set_defaults(handler=command_test_cleanup)
test_account_reset = subparsers.add_parser("test-account-reset")
test_account_reset.add_argument("--exe", required=True)
test_account_reset.add_argument("--portable-root")
test_account_reset.set_defaults(handler=command_test_account_reset)
fence_create = subparsers.add_parser("fence-create")
fence_create.add_argument("--file", required=True)
fence_create.add_argument("--root", default=".")
fence_create.add_argument("paths", nargs="*")
fence_create.set_defaults(handler=command_fence_create)
fence_check = subparsers.add_parser("fence-check")
fence_check.add_argument("--file", required=True)
fence_check.add_argument("--root", default=".")
fence_check.set_defaults(handler=command_fence_check)
finish = subparsers.add_parser("finish")
add_common_arguments(finish)
finish.add_argument("--task", required=True)

View File

@@ -4,6 +4,7 @@ import contextlib
import datetime
import io
import json
import os
from pathlib import Path
import subprocess
import tempfile
@@ -875,5 +876,419 @@ inbox_receipt: receipts/2026/07/19/test.md
self.assertIn("projects/old-project/tasks.md", staged)
def write_fake_exe(path, script):
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text("#!/bin/sh\n" + script, encoding="utf-8")
path.chmod(0o755)
return path
def make_portable_root(root):
debug = root / "out" / "Debug"
golden = debug / workspace.PORTABLE_GOLDEN
(golden / "tdata").mkdir(parents=True)
(golden / "tdata" / "key_data").write_text("golden\n", encoding="utf-8")
return debug
def source_repo_with_task(root):
source = root / "source"
git_repo(source)
(source / "Telegram" / "build").mkdir(parents=True)
tracked = source / "tracked.txt"
tracked.write_text("base\n", encoding="utf-8")
git(source, "add", "tracked.txt")
git(source, "commit", "-m", "Create baseline")
slot = root / "slot"
work = slot / "tasks" / TASK_ID / "work"
work.mkdir(parents=True)
config = {"source_root": str(source)}
return source, slot, work, config
def run_command(handler, **kwargs):
out = io.StringIO()
with contextlib.redirect_stdout(out):
handler(SimpleNamespace(**kwargs))
return json.loads(out.getvalue())
class MechanicsTest(unittest.TestCase):
def test_parse_env_values_requires_name_value_pairs(self):
self.assertEqual(
workspace.parse_env_values(["A=1", "B=x=y"]),
{"A": "1", "B": "x=y"},
)
with self.assertRaises(workspace.WorkspaceError):
workspace.parse_env_values(["NOVALUE"])
with self.assertRaises(workspace.WorkspaceError):
workspace.parse_env_values(["=x"])
def test_setup_test_account_lifecycle(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
live = root / workspace.PORTABLE_LIVE
real = root / workspace.PORTABLE_REAL
golden = root / workspace.PORTABLE_GOLDEN
with self.assertRaisesRegex(workspace.WorkspaceError, "golden"):
workspace.setup_test_account(root)
(golden / "tdata").mkdir(parents=True)
(golden / "tdata" / "key_data").write_text("golden\n", encoding="utf-8")
self.assertEqual(workspace.setup_test_account(root), "fresh-copy")
self.assertTrue((live / workspace.PORTABLE_MARKER).is_file())
self.assertTrue((golden / "tdata" / "key_data").is_file())
self.assertEqual(
workspace.setup_test_account(root),
"reused-marked-live",
)
(live / workspace.PORTABLE_MARKER).unlink()
(live / "tdata" / "user_file").write_text("mine\n", encoding="utf-8")
self.assertEqual(workspace.setup_test_account(root), "preserved-real")
self.assertTrue((real / "tdata" / "user_file").is_file())
self.assertTrue((live / workspace.PORTABLE_MARKER).is_file())
self.assertFalse((live / "tdata" / "user_file").exists())
(live / workspace.PORTABLE_MARKER).unlink()
self.assertEqual(
workspace.setup_test_account(root),
"replaced-manual-live",
)
self.assertTrue((real / "tdata" / "user_file").is_file())
self.assertEqual(
workspace.reset_broken_test_account(root),
"fresh-copy",
)
(live / workspace.PORTABLE_MARKER).unlink()
with self.assertRaisesRegex(workspace.WorkspaceError, "unmarked"):
workspace.reset_broken_test_account(root)
@unittest.skipUnless(os.name == "posix", "posix launch mechanics")
def test_test_run_reports_complete_markers(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
debug = make_portable_root(root)
exe = write_fake_exe(debug / "Telegram", (
'LOG="$TDESKTOP_TEST_EVIDENCE_DIR/test_log.txt"\n'
'echo "TEST_STEP: open settings" >> "$LOG"\n'
'echo "TEST_RESULT: PASS: row painted" >> "$LOG"\n'
'echo "SCREENSHOT: /tmp/fake.png" >> "$LOG"\n'
'echo "TEST_COMPLETE" >> "$LOG"\n'
"exit 0\n"
))
result = run_command(
workspace.command_test_run,
exe=str(exe),
run_dir=str(root / "run1"),
portable_root=None,
deadline=20.0,
quiet=10.0,
grace=5.0,
env=["EXTRA_FLAG=1"],
)
self.assertEqual(result["outcome"], "exited")
self.assertEqual(result["verdict_hint"], "complete")
self.assertTrue(result["test_complete"])
self.assertEqual(result["exit_code"], 0)
self.assertEqual(result["account"], "fresh-copy")
self.assertEqual(result["markers"]["pass"], ["row painted"])
self.assertEqual(result["markers"]["screenshots"], ["/tmp/fake.png"])
self.assertFalse(result["crash_report_fresh"])
@unittest.skipUnless(os.name == "posix", "posix launch mechanics")
def test_test_run_reports_crash_diagnostics(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
debug = make_portable_root(root)
exe = write_fake_exe(debug / "Telegram", (
'LOG="$TDESKTOP_TEST_EVIDENCE_DIR/test_log.txt"\n'
'echo "TEST_STEP: about to crash" >> "$LOG"\n'
f'mkdir -p "{debug}/{workspace.PORTABLE_LIVE}/tdata"\n'
f'echo "Assertion: boom" > "{debug}/{workspace.PORTABLE_LIVE}/tdata/working"\n'
"exit 0\n"
))
result = run_command(
workspace.command_test_run,
exe=str(exe),
run_dir=str(root / "run1"),
portable_root=None,
deadline=20.0,
quiet=10.0,
grace=5.0,
env=None,
)
self.assertEqual(result["outcome"], "exited")
self.assertEqual(result["verdict_hint"], "crash")
self.assertFalse(result["test_complete"])
self.assertTrue(result["crash_report_fresh"])
self.assertIn("Assertion: boom", result["crash_report_excerpt"])
@unittest.skipUnless(os.name == "posix", "posix launch mechanics")
def test_test_run_kills_on_deadline(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
debug = make_portable_root(root)
exe = write_fake_exe(debug / "Telegram", "sleep 30\n")
result = run_command(
workspace.command_test_run,
exe=str(exe),
run_dir=str(root / "run1"),
portable_root=None,
deadline=2.0,
quiet=30.0,
grace=5.0,
env=None,
)
self.assertEqual(result["outcome"], "deadline-killed")
self.assertEqual(result["verdict_hint"], "hang")
self.assertFalse(result["test_complete"])
def test_portable_root_for_prefers_app_bundle_parent(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
binary = (
root / "out" / "Debug" / "Telegram.app"
/ "Contents" / "MacOS" / "Telegram"
)
binary.parent.mkdir(parents=True)
binary.write_text("", encoding="utf-8")
self.assertEqual(
workspace.portable_root_for(binary, None),
root / "out" / "Debug",
)
plain = root / "out" / "Debug" / "Telegram.exe"
plain.write_text("", encoding="utf-8")
self.assertEqual(
workspace.portable_root_for(plain, None),
root / "out" / "Debug",
)
def test_overlay_save_and_apply_roundtrip(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, slot, work, config = source_repo_with_task(root)
git(
source, "update-ref",
workspace.source_task_ref(TASK_ID, "run"), "HEAD",
)
tracked = source / "tracked.txt"
tracked.write_text("base\noverlay\n", encoding="utf-8")
(work / workspace.OVERLAY_PATHS_FILE).write_text(
"tracked.txt\n", encoding="utf-8",
)
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
saved = run_command(
workspace.command_overlay_save,
task=TASK_ID,
restore="run",
)
self.assertGreater(saved["patch_bytes"], 0)
self.assertEqual(saved["restored"], ["tracked.txt"])
self.assertEqual(
tracked.read_text(encoding="utf-8"),
"base\n",
)
self.assertEqual(workspace.changed_paths(source), [])
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
applied = run_command(
workspace.command_overlay_apply,
task=TASK_ID,
)
self.assertTrue(applied["applied"])
self.assertEqual(applied["conflicts"], [])
self.assertEqual(
tracked.read_text(encoding="utf-8"),
"base\noverlay\n",
)
def test_overlay_save_rejects_uninventoried_and_untracked_paths(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, slot, work, config = source_repo_with_task(root)
(work / workspace.OVERLAY_PATHS_FILE).write_text(
"tracked.txt\n", encoding="utf-8",
)
(source / "tracked.txt").write_text("base\noverlay\n", encoding="utf-8")
(source / "stray.txt").write_text("stray\n", encoding="utf-8")
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
with self.assertRaisesRegex(workspace.WorkspaceError, "untracked"):
run_command(
workspace.command_overlay_save,
task=TASK_ID,
restore="run",
)
(source / "stray.txt").unlink()
other = source / "other.txt"
other.write_text("tracked other\n", encoding="utf-8")
git(source, "add", "other.txt")
git(source, "commit", "-m", "Add other")
other.write_text("dirty\n", encoding="utf-8")
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
with self.assertRaisesRegex(workspace.WorkspaceError, "outside"):
run_command(
workspace.command_overlay_save,
task=TASK_ID,
restore="run",
)
def test_source_commit_stages_owned_paths_and_marks_green(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, slot, work, config = source_repo_with_task(root)
git(
source, "update-ref",
workspace.source_task_ref(TASK_ID, "base"), "HEAD",
)
(work / "owned-paths.txt").write_text(
"tracked.txt\n", encoding="utf-8",
)
(source / "tracked.txt").write_text("task\n", encoding="utf-8")
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
result = run_command(
workspace.command_source_commit,
task=TASK_ID,
subject="Correct peer actions",
mark_green=True,
)
self.assertEqual(result["committed"], ["tracked.txt"])
self.assertTrue(result["marked_green"])
message = git(source, "show", "-s", "--format=%B", "HEAD")
self.assertEqual(
message,
f"Correct peer actions\n\nTask: {TASK_ID}",
)
head = git(source, "rev-parse", "HEAD")
self.assertEqual(
workspace.resolved_ref(
source, workspace.source_task_ref(TASK_ID, "green"),
),
head,
)
self.assertEqual(
workspace.resolved_ref(
source, workspace.source_task_ref(TASK_ID, "run"),
),
head,
)
verified = run_command(
workspace.command_source_verify_commit,
source_root=str(source),
task=TASK_ID,
ref="HEAD",
)
self.assertTrue(verified["valid"])
self.assertEqual(verified["subject"], "Correct peer actions")
def test_source_commit_rejects_paths_outside_owned_set(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, slot, work, config = source_repo_with_task(root)
(work / "owned-paths.txt").write_text(
"tracked.txt\n", encoding="utf-8",
)
(source / "tracked.txt").write_text("task\n", encoding="utf-8")
(source / "extra.txt").write_text("extra\n", encoding="utf-8")
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
with self.assertRaisesRegex(workspace.WorkspaceError, "extra.txt"):
run_command(
workspace.command_source_commit,
task=TASK_ID,
subject="Correct peer actions",
mark_green=False,
)
with self.assertRaisesRegex(workspace.WorkspaceError, "single"):
run_command(
workspace.command_source_commit,
task=TASK_ID,
subject="Bad\nsubject",
mark_green=False,
)
with self.assertRaisesRegex(workspace.WorkspaceError, "too long"):
run_command(
workspace.command_source_commit,
task=TASK_ID,
subject="x" * 80,
mark_green=False,
)
def test_fence_create_and_check_detects_changes(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
(root / "a.png").write_bytes(b"aaa")
(root / "sub").mkdir()
(root / "sub" / "b.png").write_bytes(b"bbb")
baseline = root / "fence.txt"
created = run_command(
workspace.command_fence_create,
file=str(baseline),
root=str(root),
paths=["a.png", "sub/b.png"],
)
self.assertEqual(created["paths"], 2)
checked = run_command(
workspace.command_fence_check,
file=str(baseline),
root=str(root),
)
self.assertTrue(checked["ok"])
(root / "a.png").write_bytes(b"changed")
(root / "sub" / "b.png").unlink()
out = io.StringIO()
with contextlib.redirect_stdout(out):
with self.assertRaises(SystemExit):
workspace.command_fence_check(SimpleNamespace(
file=str(baseline),
root=str(root),
))
result = json.loads(out.getvalue())
self.assertFalse(result["ok"])
self.assertEqual(result["mismatched"], ["a.png"])
self.assertEqual(result["missing"], ["sub/b.png"])
def test_source_preflight_reports_dirty_and_account_state(self):
with tempfile.TemporaryDirectory() as temporary:
root = Path(temporary)
source, slot, work, config = source_repo_with_task(root)
(work / "owned-paths.txt").write_text(
"tracked.txt\n", encoding="utf-8",
)
(source / "tracked.txt").write_text("dirty\n", encoding="utf-8")
(source / "unrelated.txt").write_text("stray\n", encoding="utf-8")
debug = make_portable_root(root)
exe = write_fake_exe(debug / "Telegram", "exit 0\n")
with mock.patch.object(
workspace, "task_action_config", return_value=(config, slot),
):
result = run_command(
workspace.command_source_preflight,
task=TASK_ID,
exe=str(exe),
)
self.assertFalse(result["source_clean"])
self.assertIn("tracked.txt", result["dirty"])
self.assertEqual(result["dirty_outside_owned"], ["unrelated.txt"])
self.assertTrue(result["owned_paths_present"])
self.assertTrue(result["exe_present"])
self.assertTrue(result["golden_account_present"])
self.assertFalse(result["live_marker_present"])
if __name__ == "__main__":
unittest.main()