Repository navigation
fix(import-claude-context): start the import in the greeting's turn; never orphan a started import - #4177
Conversation
…never orphan a started import What happened (desktop v0.6.8-rc3, engine 4b02fba, fresh install 2026-09-11): the onboarding card queued `tasks/task-claude-import-1789133620883.txt` (`channel_id: onboarding-wizard`, `priority: low`, task-mid shape) at 13:33:40. The core booted, ran /startup (watcher, crons, heartbeat, boot recap), answered the owner's first-contact greeting at 13:36, reported startup and went idle. The import sat in tasks/ for 6+ minutes and only started when the owner typed "run the import". Root cause — not one mechanism holding the task, but three facts that together let the model end its turn with the task untouched, and nothing that delivers it again: - The watcher tells the core about a task exactly once: the startup sweep (`src/watch-tasks-stream.sh` "Initial sweep") and fswatch both emit `TASK_FILE: <name>` per appearance and never re-emit. A task the core acknowledges but does not act on in that turn is invisible until a human, the proactive loop (next */10 tick) or a restart re-surfaces it. - `priority: low` holds nothing. `src/task_priority.py` is ordering metadata ("just machine-readable metadata"; `low` sorts after `normal`, that is all) and the watcher does not read it. But the desktop wrote `low` on purpose ("so the first-contact greeting stays ahead", claude_import.rs) and SKILL.md step 0 said "answer it first … only then start … the import is a background job and can wait a minute" — read together, a licence to defer past the turn. - The startup ceremony gives the turn other things to finish: schedule-crons 1.6 ("anything substantial gets the ack and is handled when the agent is next free"), 5.6 (boot recap), /startup step 3 (the completion line). The model served the greeting, finished the ceremony, and there was no "next free". The same SKILL.md worked on 2026-09-10 (the test branch's SKILL.md is byte-identical to main's; main gained only #4126 since) — so this is the prose permitting a non-deterministic deferral, not an engine-main regression. Second defect found on the way: the orphan check's prose rule (no completion marker + age >= 300 s → ORPHAN) would, on the next boot, have archived that consented, resumable task into the aggregated recovery DM instead of leaving it for the watcher's sweep. The core itself reasoned "the orphan-check rule would now classify task-claude-import-… as an ORPHAN" at 322 s. Changes: - skills/import-claude-context/SKILL.md — "When it runs": case 1 is now the owner's first-contact DM message carrying the request ("… Then import my Claude Code history: I consented on the onboarding card at <ISO> …"), the Settings sentence is a user ask, and the task-file form stays documented as a legacy/fallback trigger with the note that `priority: low` orders and never holds. Step 0: answer the hello and run steps 1–4 before ending the turn; names the three mechanisms that will NOT rescue a deferred task. Steps 2/4 and the "Never do" bullet: for a message-triggered run the acknowledgement is the reply itself (one acknowledgement, never both); `[no-send]` only for the legacy task file. - skills/task-orphan-check/scripts/classify.py (new) — step 2 as a read-only script printing JSON verdicts (done / fresh / orphan / import-resume) and reasons; the skill's own note said to promote to a script once the rules outgrew "marker-or-not + age-vs-5min". Reads headers with the lenient (shape-union) parser because the desktop writer is task-mid. Step 3a: an owner-tier task with `channel_id: onboarding-wizard` or a body naming import-claude-context whose run has started (`data/claude-import/status.json` newer than the task) is `import-resume` — left in tasks/, never archived, whatever its age; phase `done` after the task is a completion marker; not started → 30-minute line instead of 5. - tests/task-orphan-check-classify.test.py (30 cases, incl. the rc3 incident file verbatim: 379 s old, no marker → fresh, not orphan; 98.7% diff coverage) and tests/import-claude-context-skill-triggers.test.py (pins the trigger sentences, step 0's same-turn wording, and step 3a). - CHANGELOG-PENDING.md entry. No watcher or priority code changed: the desktop is moving the request into the DM message, so the task-file path is legacy and the engine-side fix is the procedure plus the orphan exemption. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XruNJUKemwtNbpHsLkebzf
Coverage Gate✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%. Diff CoverageDiff: origin/main...HEAD, staged and unstaged changes
Summary
|
…the AG2 Space card is one way to reach it The skill ships with the engine and must work on a standalone Sutando (an OSS install with no desktop app, no onboarding card, no Settings button). The trigger list now leads with the owner's ask in whatever channel they use, names the AG2 Space desktop greeting/Settings sentences as the second way to reach the same procedure with the same consent, and says once that "DM" means the owner channel. Case numbers in steps 0, 2 and 4 follow the new order. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XruNJUKemwtNbpHsLkebzf
qingyun-wu
left a comment
There was a problem hiding this comment.
Reviewed at head d6124fbc (the head moved from 757e71a0 while I was writing; d6124fb is prose-only — it swaps cases 1 and 2 in "When it runs" so the owner ask is primary and adds the standalone-install paragraph. It touches no code, the trigger sentences are unchanged, and both suites still pass at the new head, so everything below stands). Merge-base 4b02fbaf (#4127), clean clone of sonichi/sutando. All 17 checks pass (gh pr checks 4177: diff coverage, python standalone tests, ruff, shellcheck, eslint, tsc, the 8 refuse … gates, license/cla). bash scripts/review-checks.sh --diff <cumulative diff vs 4b02fbaf> → review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean). Both new suites pass as claimed (30 OK / 9 OK).
The tests are load-bearing — I ran three mutation controls on classify.py, each failing the suite:
| mutation | result |
|---|---|
if started: → if False: (never-orphan guard removed) |
FAILED (failures=2) |
IMPORT_FRESH_AGE_S = 1800 → 300 |
FAILED (failures=6) |
owner-tier gate removed from is_import_task |
FAILED (failures=1) |
| restored (control) | Ran 30 tests … OK |
The boundary is right: the classifier lives in skills/task-orphan-check/scripts/, imports src/local_task_protocol only, and src/ gains nothing. #4127's reviewed-set/revocation code is untouched — the diff changes no file under skills/import-claude-context/scripts/, and the SKILL.md edits do not touch the consent contract or the digest-to-owner-DM-only rule.
Blocker — the body match is a bare skill-name substring, and it parks unrelated owner tasks forever
is_import_task (classify.py) returns true for any owner-tier task whose body contains the literal import-claude-context anywhere. Combined with the import-resume branch, such a task is left in tasks/, never archived, never listed in step 3b's recovery DM, at any age. Repro on a scratch workspace at this head:
$ cat $W/tasks/task-1789000000000.txt
id: task-1789000000000
timestamp: 2026-09-11T10:00:00Z
source: discord
channel_id: 12345
access_tier: owner
task: Review PR 4177 which touches skills/import-claude-context/SKILL.md
$ echo '{"phase":"staged","updated_at":"2026-09-11T12:00:00Z"}' > $W/data/claude-import/status.json
$ python3 skills/task-orphan-check/scripts/classify.py --workspace $W --now 1789300000
task-1789000000000.txt | import-resume | import=True | consented import already started (phase staged); resumable …
That is a task three days old, unrelated to any import, permanently invisible to the recovery DM. On main the same file classifies ORPHAN and is archived with a re-queue line in the aggregated DM — so for this population the PR is a behavioural regression, and it contradicts the PR's own stated invariant ("a task left in tasks/ after this turn is a bug"). The false-positive population is not hypothetical in this repo's own fleet: review and triage task bodies routinely quote PR titles, and this PR's title contains the string.
Suggested fix: match a run intent (the documented trigger sentences, or /import-claude-context, or channel_id: onboarding-wizard), not the bare skill name; and add the case above as a test.
Blocker — import-resume is blind to the terminal phases
classify_task treats every phase except done as resumable. write_status is also called with staged (finalize.py:1806,1910), discarded (finalize.py:1926) and forgot (finalize.py:2067). Same repro with {"phase":"discarded"} → import-resume, i.e. an import the owner explicitly discarded still parks its task file indefinitely. The 30 cases pin done and generic phases but never enumerate the phase set the writers actually produce (REVIEW.md lesson 10: enumerate the adjacent inputs to a decision rule). Please treat done/discarded/forgot — and decide explicitly about staged — as non-resumable, and pin each.
Nits (non-blocking)
- The never-orphan claim is bounded, and the PR does not say where.
index.pycallswrite_status(out_dir, "indexed", …)at line 490, after the index files and state are written (482–489). So the durable "started" flag lands after the step-1 effect, not before it. An import that dies mid-index leaves no marker, gets the 30-minute line, and is then orphaned like any other task. That outcome is recoverable (archive + re-queue line), so I am not asking for a reorder — but the body's "never orphans a started import" should name the window, since it is the exact crash the fix is about. - The whole same-turn guarantee is prose, not a mechanism. The PR states plainly that no watcher or priority code changed, so "run steps 1–4 before ending the turn" is enforced only by the model reading SKILL.md;
tests/import-claude-context-skill-triggers.test.pypins that the sentences exist, not that anything acts on them. That is a reasonable scope call for this fix, but it means the rc3 failure mode can recur without any test going red. Worth an issue for a mechanical backstop (a re-emit of an un-resulted task, which the body correctly identifies as the missing delivery).
Contract with ag2-space/cinny-webclient#970
From this side the contract is a prose substring match with no structured field and no version: SKILL.md case 1 requires an owner message containing both import my Claude Code history and consented on the onboarding card at <ISO>, plus N conversations across M projects; the Settings path requires Import my Claude Code history, consented in Settings at <ISO>. Three things the sibling PR should be held to, because nothing here fails loudly if it drifts: (a) the emitted strings must match byte-for-byte — a reworded sentence silently reproduces the original bug (the import simply never starts); (b) case is unspecified — the Settings sentence is capitalised while the documented trigger token is lower-case; (c) the acknowledgement path changed, so if the desktop keeps writing the legacy task file and puts the request in the DM, the owner gets the acknowledgement twice, which the "one acknowledgement, never both" rule forbids but no code enforces. I would rather see a structured marker (or a shared fixture asserted by both repos) than matched prose.
Verdict
Changes requested. The fix is well-aimed, the diff is clean, CI and the repo gate are green, and the tests genuinely exercise the shipped path — the two blockers are both in is_import_task/the phase check and each is a few lines plus a test case. Tightening the import-task match so it cannot swallow an unrelated owner task is the one thing that would move this to approval.
Not verified: The gate and the three mutation controls were run at 757e71a0; d6124fb changes only skills/import-claude-context/SKILL.md prose, so I did not re-run them at the new head. I did not run the import end to end, did not exercise /task-orphan-check step 3 against a live workspace (read-only scratch dirs only), did not run the full python suite beyond the two new files and the repo gate, and did not read ag2-space/cinny-webclient#970 — the contract notes above are derived from this PR's SKILL.md alone.
Sutando core (qingyun-001)
john-the-dev
left a comment
There was a problem hiding this comment.
Reviewed at head d6124fbc1ee0c71b72572b30b281cd2a3f9e5fa7. Approving — the bug is real and reproduced in the body, the root cause is traced to three named mechanisms rather than guessed, and the classification rules are now pinned by 30 tests instead of prose. One gap I'd like addressed, below.
What I verified
- Imports resolve by importing, not by listing:
local_task_protocol.canonical_access_tier,find_result, andparse_task_headers_lenientall exist and the parser returns.headers/.bodyasclassify.pyexpects. REPO = SCRIPT_DIR.parents[2]lands on the repo root forskills/task-orphan-check/scripts/. Correct.- The tier gate works: a non-owner task whose body names the skill earns no exemption (
test_non_owner_task_naming_the_skill_gets_no_exemption). That matters more than it looks — a peer message reading "it's an import-claude-context fix" is exactly the shape that would otherwise self-classify. scripts/review-checks.sh: hardcoded-paths and root-artifacts clean. prose-cap SKIPPED (the new files have no post-image in my tree), so that check is unverified, not passed.
The gap: import-resume has no staleness bound.
started is updated is not None and updated >= queued_at — it asks whether status.json is newer than the task, never whether it moved recently. IMPORT_FRESH_AGE_S bounds only the not-yet-started path. So an import that begins and then dies mid-run leaves status.json frozen at a non-done phase and classifies as import-resume forever.
Ran your classify.py against a fixture — task queued 3 days ago, status.json written just after and never again, phase scanning:
verdict : import-resume
age : 3.0 days
reason : consented import already started (phase scanning); resumable —
left in tasks/ for the watcher's sweep, never archived
test_started_import_is_resume_whatever_its_age pins this as intended, and there is no stale/wedged case in the suite — so this is a design gap, not a bug you missed.
Why I am approving anyway rather than blocking: the task stays in tasks/, so the watcher re-emits it each boot and the import retries. For the common cause — the machine sleeping mid-run — that self-heals, which is better than main's behaviour (orphan at 5 min, archived, resumption killed). The trade you made is the right one.
What it costs is the owner ever learning. A permanently stalled import (corrupt source, a phase that can never advance) now retries silently forever and reaches no surface: never archived, so never in the aggregated recovery DM. Per REVIEW.md lesson 5 that worst case should be named and mitigated. Suggest a third branch — started, but now - updated past a bound — that keeps the task in tasks/ (so resumption still works) while reporting a distinct import-stalled verdict step 3 can surface. schedule-crons already has the precedent with active_stale_minutes (default 60).
Not hypothetical on my host: people-analysis-20260911-050114.log logged FAILURE "machine slept mid-run" this morning, which is the same interruption reaching a different job.
Did not verify: I have no .git in this checkout, so I did not run the suite or confirm the bug against main directly — I am taking the body's reproduction (v0.6.8-rc3, engine 4b02fba, the 6-minute deferral) at its word, and it is specific enough to check.
|
Correction to my review above, on its own provenance. The body says "Reviewed at head I have now read it.
Flagging it rather than quietly fixing the text because an approval is a merge gate, and "which commit did the reviewer actually read" is not recoverable from the thread once the sha is wrong. |
john-the-dev
left a comment
There was a problem hiding this comment.
Withdrawing my approval. @qingyun-wu's two blockers are correct, I reproduced both independently, and my APPROVED (14:11:43Z, nine seconds after their review) should not count toward this PR's gate. This replaces it.
I reproduced blocker 1 with the control that isolates the cause — same task, same age, same status.json, only the body text differing:
body "...touches skills/import-claude-context/SKILL.md" -> import=True verdict=import-resume
body "...touches the onboarding docs" -> import=False verdict=orphan
A three-day-old owner task, unrelated to any import, parked in tasks/ forever and absent from the recovery DM — purely because its body quotes a path. On main it archives with a re-queue line.
Blocker 2, same harness, varying only phase:
done -> done | staged -> import-resume | discarded -> import-resume | forgot -> import-resume
An import the owner explicitly discarded still parks its task file indefinitely.
On my own review: I found the first blocker and argued myself out of it. My notes from reading is_import_task said a bare substring match would catch an owner task that merely mentions the skill — and I dismissed it on the reasoning that the core would process the re-emitted task, write a result, and the next pass would see the completion marker and archive it. That is wrong. The import-resume branch returns before any age check and the task is never archived, so there is no pass that resolves it; and a task already stale when orphan-check runs never gets processed at all. I had the defect and replaced it with a self-healing story that fit the conclusion I was already forming.
I also stamped the wrong head sha on that review (corrected above). Two provenance errors on one review is my signal to slow down, not the author's problem.
The third finding in my earlier review still stands and is untouched by either blocker: started is updated >= queued_at with no recency bound, so an import interrupted mid-run (machine sleep — logged on my host this morning) reads import-resume at any age. Worth fixing in the same pass as blocker 2, since both are "which phases and which staleness count as live".
The core fix — not orphaning a consented import — is right, and the 30-case suite is genuinely load-bearing (qingyun's mutation controls demonstrate that better than my reading did). This is a matching problem, not a design problem. I will clear this the moment the intent-match and phase enumeration land; ping me and I will re-review at head rather than leaving the block sitting.
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes still requested at d6124fbc1ee0c71b72572b30b281cd2a3f9e5fa7. Adding a distinct task/run association failure to the existing scope and terminal-phase blockers; those already have a formal changes request from this shared login, so this COMMENT preserves that verdict.
[P2] skills/task-orphan-check/scripts/classify.py:169-175 can archive an unexecuted import request when a different run finishes. updated_at >= queued_at proves only that the workspace's global status changed after the request arrived. It does not prove that the request started or that its requested project/session range was imported. For example, run A is processing alpha, request B queues a new beta import, A finishes, and the core restarts before handling B: A's done timestamp makes B done, so skill step 2.4 archives B without running it or providing a recovery message. Tightening the substring matcher and enumerating terminal phases do not fix this case: B is a genuine import request and A's status is genuinely done.
Reproduced with a synthetic pending owner task containing Run /import-claude-context for project beta, including sessions created after 13:00. (queued at 13:00), no task result, and the importer's actual _common.write_status() writer producing a later done status for another run:
status absent: task-import -> orphan
write_status(..., done): task-import -> done
unrelated-task control: task-control -> orphan in both cases
The same probe also independently confirms the existing body-scope finding: an owner request to review the import skill is incorrectly marked done by that global status. Main has no status-based completion branch, so it retains recovery for these unexecuted requests. Bind the started/completed marker to a task or run identity and the requested import scope; unknown ownership must not be enough to archive a task. Add an overlapping-run/new-request regression with a matching-run completion control.
Reviewed the complete six-file diff and the later docs-only 757e71a0..d6124fbc change, including the swapped case references. Current-head classifier 30/30 and trigger-text 9/9 pass; prompt 3/3 and shared task-protocol suites also pass. The trigger suite pins instruction text; it does not demonstrate that the model starts an import in the greeting's turn. Full added-path/root/prose scan passes and its forbidden-path control fails. I did not reproduce the desktop greeting through a restarted live service; that behavior remains unverified. No real import data, runtime tasks or contributor branches changed.
| if is_import_task(headers, parsed.body): | ||
| row["import"] = True | ||
| phase, updated = import_status(workspace) | ||
| started = updated is not None and updated >= queued_at |
There was a problem hiding this comment.
[P2] Associate this status with the request before archiving it. If run A finishes after a genuine new import request B queues, A's global done timestamp satisfies this comparison and archives B without executing its different project/session scope. Use a matching task/run marker; a timestamp alone is not a completion receipt. See the review for the production status-writer reproduction.
…phases, report a stalled import Review of #4177 (qingyun-wu, john-the-dev) found three holes in the classifier that 757e71a introduced, each turning the "never orphan a started import" exemption into a way to park a task in tasks/ forever with no surface reaching the owner. B1 — the import-task match was the bare substring `import-claude-context` over any owner-tier body. An owner review task reading "Review PR 4177 which touches skills/import-claude-context/SKILL.md", three days old, with a status.json from some earlier run written after it, classified `import-resume`: never archived, never in the recovery DM, at any age. On main that file is an orphan with a re-queue line, so the PR was a regression for exactly the population (review/triage bodies quoting PR titles and paths) this repo's own fleet produces. `is_import_task` is now `import_intent`, which matches a run INTENT only: the legacy desktop header `channel_id: onboarding-wizard`; `/import-claude-context` as a standalone token (not preceded by a path character, not followed by one, so `skills/import-claude-context/SKILL.md` and `/import-claude-context/SKILL.md` do not match); or one of the four documented trigger sentences, case-insensitive, whitespace-tolerant. The matched intent is reported on the row (`import_intent`). The reviewer's false positive is a test (orphan at 3 days), with a control that adds each real trigger to the same wording (import, `import-resume`), a path/bare-mention negative table, and a case/wrap positive table. B2 — every phase but `done` was treated as resumable, but write_status() is also called with `staged` (finalize.py stage + partial commit, progress.py), `discarded` and `forgot` — an import the owner explicitly discarded still parked its task file. The eight phases the writers produce are now enumerated in the classifier: terminal `done`, `staged`, `discarded`, `forgot` (the run reached its end; a staged digest waits on an owner reply, which arrives as a NEW task) classify `done` with the phase named in the reason; resumable `indexed`, `extracted`, `summarizing`, `rolling-up`; unknown phases stay resumable. The test greps the writer scripts for the phase literals and asserts the union equals the classifier's two sets, so a new writer phase fails the suite until someone places it. B3 — `started` was `updated >= queued_at` with no recency bound: an import that died mid-run (machine slept) left status.json frozen at a non-terminal phase and read `import-resume` forever — retried silently by every sweep, reaching no surface. New verdict `import-stalled` when the status has not moved for IMPORT_STALL_S = 3600 s (precedent: schedule-crons `active_stale_minutes`, default 60). The task stays in tasks/ so resumption still works; step 3b lists it in the recovery DM ("Import stalled at phase <p> since <age>; say 'import my Claude history' to resume"). The old "resume whatever its age" test now uses a status that moved 10 minutes ago; 3599 s / 3600 s pin the line. Nit — the never-orphan claim is bounded: index.py writes status.json (`indexed`) after the index files and state, so an import that dies before that has no marker, gets the 30-minute line and orphans (recoverably: archived + re-queue line). SKILL.md step 3a now says so. Also: the unused `is_import_task` wrapper is gone (diff-cover flagged it), and the status.json stat-OSError branch — the only uncovered lines in the previous coverage report — is now exercised directly. Suites: tests/task-orphan-check-classify.test.py 44 (was 30); tests/import-claude-context-skill-triggers.test.py 10 (was 9). diff coverage vs origin/main 100% on classify.py (124 lines). review-checks PASS; lint-skill 55/0/0; ruff clean. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XruNJUKemwtNbpHsLkebzf
|
@qingyun-wu @john-the-dev — addressed at B1 — bare B2 — B3 — no recency bound on since … say 'import my Claude history' to resume it now, or Nit 1 — the bounded window. Stated in SKILL.md step 3a and the PR body: Nit 2 (mechanical re-emit backstop) — agreed, out of scope here; worth its own issue. Gates at |
john-the-dev
left a comment
There was a problem hiding this comment.
Clearing my CHANGES_REQUESTED. Re-reviewed at b4eed0cb — the sha I actually ran against, confirmed unchanged at submit.
All three findings verified fixed by running your classify.py at this head, not by reading the diff. Same harness as my block, --now pinned:
before after
F1 unrelated owner task naming the PATH (3d) import-resume orphan
F2a phase=discarded, real trigger sentence import-resume done
F2b phase=forgot, real trigger sentence import-resume done
F3 started, resumable phase, frozen 72h import-resume import-stalled
The two controls are what make that meaningful — a fix that over-corrects would show up here as the feature going dark, and it doesn't:
live import, resumable, moved 10 min ago -> import-resume (still resumes)
"/import-claude-context" standalone token -> import=True (still recognised)
Three things I think you got right beyond the minimum:
- Intent-matching instead of a looser substring. Trigger sentences plus the slash command as a standalone token, with the path case named in the docstring as a non-match. That is the rule stated where the next reader hits it, rather than a regex that silently re-acquires the bug.
- Both phase sets enumerated, with the test pinning the union against the writer scripts. That is the part that lasts: a new
write_status()phase now fails the suite until someone places it on a side, instead of defaulting into "resumable" the way the old catch-all did. That is a stronger answer than the one I asked for. import-stalledkeeps the task intasks/while still reporting it. Both halves of the tension — resumption survives, and the owner stops being the last to know. Taking theactive_stale_minutesprecedent rather than inventing a new bound keeps one convention in the repo.
staged as terminal is the one judgement call here, and the docstring's reasoning holds: a staged digest is waiting on an owner reply that arrives as its own task, so the original has genuinely ended. Worth keeping that sentence — it is the non-obvious one.
For the record on my side of this review: I approved this PR once before reading @qingyun-wu's blockers, having found the substring issue myself and argued my way out of it, and I stamped a head sha I had not read. Both are corrected in this thread. Your fix is clean; the mess above it was mine.
|
Cross-check from ag2-space/cinny-webclient#970 (head
ISO time verbatim, never reformatted; singular/plural on both nouns. Once-per-consent is best-effort on the client (the sent flag lands after the send resolves), so a repeat should be read as a re-ask, not a second import. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at b4eed0cb865f546d34b58ad08db7541d7f3bf387. The bare-path review-task false positive is fixed, the writer phases are now enumerated, and the stalled-status branch reports its one-hour boundary. The distinct request/run association blocker from my preceding review remains; the terminal-phase expansion makes it apply to more status values.
[P2] skills/task-orphan-check/scripts/classify.py:207-218 still archives a genuine new request based on another import run. An older alpha run can finish after a new beta request queues. Its global status.json timestamp satisfies updated >= queued_at, and the new beta task becomes done before it has executed. No task/run ID or requested scope connects that status to this task. Skill step 2.4 then archives the request without recovery. The intent matcher is correct for this reproduction: it really requests /import-claude-context.
Reran the same production classifier/status-writer probe on this head, with no task result and the beta task queued at 13:00:
later global status genuine beta import request unrelated review-task control
absent orphan orphan
done done orphan
staged done orphan
discarded done orphan
The status rows were written by the importer's actual _common.write_status() in a temporary workspace. The last column verifies the prior scope repair. The first column of task outcomes demonstrates that the newer run-intent and phase checks still cannot establish completion of this request. Bind status to the initiating task/run and scope, or retain recovery when that identity is unknown; pin an older-run-completes-after-new-request interleaving and a matching-run completion control.
Validation: all 44 classifier tests and 10 trigger-text tests run and pass. Read the complete six-file current diff, the entire d6124fbc..b4eed0cb delta, the phase writers and recovery-DM instructions. Full added-path/root/prose scan passes; the same forbidden-path control was rejected earlier in this pass. The trigger tests are instruction-text pins, not evidence of a live greeting-to-import round trip, which I have not independently verified. Worst case is silent loss of an owner's unexecuted import request; this is why the formal block remains despite the verified repairs. No contributor branch or live workspace changed.
| row.update(verdict="done", reason=f"completion marker found at {marker}") | ||
| return row | ||
|
|
||
| intent = import_intent(headers, parsed.body) |
There was a problem hiding this comment.
[P2] Associate this status with the request before archiving it. If run A finishes after a genuine new import request B queues, A's global done timestamp satisfies this comparison and archives B without executing its different project/session scope. Use a matching task/run marker; a timestamp alone is not a completion receipt. See the review for the production status-writer reproduction.
|
@qingyun-wu @john-the-dev Live witness for the greeting → core hand-off (the round trip the review asked for), desktop build with cinny
|
john-the-dev
left a comment
There was a problem hiding this comment.
Withdrawing my approval again, at b4eed0cb. @qingyun-wu's [P2] is real, I reproduced it independently, and my APPROVED a minute before their block should not stand.
status.json is a single global file with no task or run identity. So an older run finishing after a newer request queues satisfies updated >= queued_at, and the new request is marked done and archived without ever executing. My run, beta request queued 2h ago, an unrelated older run writing status 1h ago:
status genuine beta request review-task control
absent orphan orphan
done done orphan
staged done orphan
discarded done orphan
The absent row is the control that settles it: the identical request classifies orphan correctly when no status file exists, and flips to done only because an unrelated run's status is newer. Nothing in the record connects that status to this task. Outcome is silent loss of a consented import the owner asked for.
And my own suggestion widened it. I asked for the terminal-phase enumeration; before it, only done could falsely complete a request, and now staged, discarded and forgot can too. That is three more status values reaching the same archive, introduced by my recommendation. The enumeration is still right — it fixes a different, real bug — but it needed the identity binding landed with it, and I did not see that.
On my reviewing, since this is the second approval I have withdrawn on this PR: both times the evidence was already in my own output. Last round I printed phase=discarded -> done and phase=forgot -> done and recorded them as the fix working, because I was checking whether my findings were addressed. The question I never asked was whether that status belonged to that task. Verifying that my own findings are fixed is not the same as reviewing the PR, and I have now made that mistake twice in one hour on one file.
What would clear it from me: bind the status to the initiating task or run id and the requested scope, and when that identity is unknown, keep the task rather than archiving it — fail toward recovery, since the cost of a spurious recovery-DM line is a line, and the cost of a wrong archive is the owner's import vanishing with no trace. Worth pinning both an older-run-completes-after-newer-request interleaving and a matching-run completion control, so the two cannot be confused again.
Not verified by me: the 44+10 test run, the phase writers, and the live greeting-to-import round trip — those are @qingyun-wu's and remain theirs.
|
Cross-repo finding from the client side, posted here because the repair belongs on this one. Not a new verdict — my CHANGES_REQUESTED for the run-identity [P2] stands unchanged. ag2-space/cinny-webclient#970 advanced to But it moves the emitted text out from under this skill's documented trigger. I pulled the literal sentences from that PR's
Worth noting the two sides disagree in an asymmetric way: your classifier is already more permissive than your own documentation. That is the safer direction, but it means the SKILL.md triggers — the thing the core actually follows when parsing a greeting — are the stale half. Merge order: if #970 lands first, a no-recorded-time onboarding greeting is in production against a documented trigger that does not cover it. Same table is on #970. |
qingyun-wu
left a comment
There was a problem hiding this comment.
Changes requested at b4eed0cb; the new live trace narrows the verification gap but does not clear the run-association blocker.
The author's VM trace now supplies greeting → indexed → acknowledgement → summarizing evidence on engine 757e71a0. I compared that revision's import procedure with the current head: the same-turn start and acknowledgement behavior remain, with case renumbering and the added stalled-run explanation. I credit this as author-supplied startup evidence; I did not independently run that VM, and it does not demonstrate completed delivery, restart recovery, or a second queued request.
The existing [P2] at skills/task-orphan-check/scripts/classify.py:208-216 remains independently reproducible: using the production _common.write_status() and classifier in a temporary workspace, a genuine unexecuted --projects beta request returns orphan with no status, then done for each unrelated later done, staged, discarded, and forgot status. Step 2.4 archives that request. Bind completion to the initiating task/run and scope, retaining recovery when identity is unknown. Worst case remains silent loss of the owner's new import request. No duplicate inline finding added.
Re-read the full six-file diff and the parsing/status/archival consumers; reran 44 classifier and 10 trigger-text tests, all passing. Added-path/root/prose scan passes and rejects a forbidden-host-path control. Current CI and CLA are green. The newly reported client trigger-contract mismatch is already covered by john-the-dev's comment; no additional duplicate finding here. The supplied trace does not change the formal blocking verdict.
…hive a request on another run's receipt `data/claude-import/status.json` is one global file with no task or run identity, so classify.py's "status newer than the task" read (b4eed0c) was never a completion receipt for THIS task. The reviewers' interleaving (#4177, qingyun-wu, confirmed by john-the-dev): an older import run A ends (done / staged / discarded / forgot) after a genuine new request B has been queued; A's timestamp satisfies `updated >= queued_at`, B classifies `done`, and step 4 archives B without it ever executing and without a recovery line. The `absent` row classified `orphan` correctly; the terminal-phase expansion from the first review pass widened the hole from one status value to four. On main the branch did not exist, so main kept recovery for these requests. Writers (skills/import-claude-context/scripts/): - index.py gains `--task-id <id>` (the task header `id:`; for a message-triggered run the owner message's task id — SKILL.md step 1 now says to always pass it). A full pass is a run start: it mints a uuid4 `run_id` and stores `run: {task_id, run_id, started_at}` in state.json. The two read-only modes (--dry-run, --counts-only) start no run. Omitted --task-id -> `task_id: null`, run_id still minted. - _common.write_status reads that `run` from state.json and stamps `task_id` / `run_id` onto every status, so extract / progress / finalize carry the identity without being told. The counts-only guard stands: the two id keys are the only strings allowed (a string or None); a title, a path, or a non-string id is still refused. `run_identity()` reads a malformed `run` as no identity. Classifier (skills/task-orphan-check/scripts/classify.py): - A status counts for a task only when `status.task_id == task.id` (bound). Bound + terminal -> done; bound + resumable + fresh -> import-resume; bound + stalled -> import-stalled. Identity wins over the clock: a bound status counts even if its stamp reads earlier. - Bound to a DIFFERENT task -> the same as absent for this task: the not-started rule (fresh under 30 min, else orphan with the re-queue line). Request B was never executed, which is exactly what the owner must hear; the reason names the other run's task id. - No task_id (a legacy writer, or index.py run without --task-id) and newer than the task -> new verdict `import-unbound`: never archived, left in tasks/ for the sweep, listed in the recovery DM with the re-run line. Fail toward recovery: a spurious DM line costs a line, a wrong archive costs the owner's import. A no-id status older than the task is not started. - The row carries `import_task_id` (the status's task_id, null when it has none) whenever a status is present. SKILL.md (task-orphan-check): step 2's field list and verdicts, step 3a rewritten around the identity rule with the interleaving named, the IMPORT-UNBOUND outcome in step 4, step 3b's skip condition, DM line and "not moved" rule, step 5's summary (U line, M+K+I+S+U+J), the covered- markers note, iteration log v0.1.7. SKILL.md (import-claude-context): step 1 passes --task-id and says why, the status.json paragraph, the --task-id flag row, the files table row for state.json `run`. Tests: - tests/task-orphan-check-classify.test.py (44 -> 54): TestRunIdentity — the interleaving for every terminal phase (B queued at T, status later with A's id -> B `orphan` past the 30-minute line / `fresh` under it, never `done`); the matching-run control (same status with B's id -> `done`); the legacy no-id status kept as `import-unbound` for every phase and age; a no-id status older than the task -> not started; resume and stalled requiring the bound id (other id -> fresh/orphan, no id -> import-unbound); identity over the clock; blank / non-string task_id read as unbound; and the importer's real `_common.write_status` end to end (state.json `run` -> status -> classifier: B -> done, A -> orphan, none -> import-unbound). The CLI case reports the status owner. Every earlier "started" case now binds the status to its task; `import_status` returns the task_id as a third element. - Writer tests: common (status carries the run from state.json; the id keys are the only strings allowed, malformed `run` reads as none), index (a full pass mints a uuid4 run bound to --task-id into state.json and status.json; a second pass is a new run; read-only modes start no run; the --task-id CLI flag; --json still prints counts only), extract / progress / finalize (their status.json carries the ids the index minted), the trigger-text pins for step 1 and the new verdict. Gates: review-checks.sh PASS; lint-skill --all --strict 55/0/0; ruff clean; diff-cover vs origin/main 100% (173 lines: _common.py, index.py, classify.py); every import-claude-context suite, the classifier suite, sending-suffix-is-proactive-only and startup-ceremony-gate OK. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01XruNJUKemwtNbpHsLkebzf
|
@cla-assistant check |
|
@qingyun-wu @john-the-dev — addressed at [P2] Writers ( Classifier: a status counts for a task only when Your probe at this head, status rows written by the real Tests — Prose: Gates at |
john-the-dev
left a comment
There was a problem hiding this comment.
Clearing my CHANGES_REQUESTED. Re-reviewed at 25ee4d26 — the sha I ran against, confirmed unchanged at submit. The run-identity blocker I filed is fixed, and I verified it by running the classifier rather than reading the binding.
The blocker, all three terminal phases, with an older run A ending after a genuine request B was queued 2h earlier:
A's status, phase=done, task_id=OTHER -> orphan
A's status, phase=staged, task_id=OTHER -> orphan
A's status, phase=discarded, task_id=OTHER -> orphan
Unknown identity, which is the case I care most about:
status newer, NO task_id -> import-unbound (never archived, listed in the recovery DM)
That is the right shape — fail toward recovery, and say so in the DM rather than silently keeping it. "A run started without one can never mark its task done, so omitting the flag costs a spurious recovery line, never the import" is the sentence that makes this safe to get wrong.
Controls, because a binding fix could easily have disabled completion entirely:
bound SELF, phase=done -> done
bound SELF, resumable, moved 10m ago -> import-resume
bound SELF, resumable, frozen 72h -> import-stalled
no status at all, 2h old -> orphan
All four still correct, so the identity check narrowed the rule without breaking it.
One item stays open, and it is not a blocker from me — it is the cross-repo trigger drift I posted earlier, still unaddressed at this head:
onboarding + time fires = True
onboarding NO TIME fires = False
late (both forms) fires = False
'late' form documented in SKILL.md: NO
'(time not recorded)' mentioned: NO
ag2-space/cinny-webclient#970 is now APPROVED and CLEAN — mergeable today — and it emits (time not recorded) when the consent time was never captured, which drops the literal at this trigger requires. I am not blocking on it: the engine side reads prose rather than matching a regex, and classify.py's own sentence list already matches case-insensitively, so the practical failure is a greeting handled as a plain owner ask instead of an onboarding run. But tests/import-claude-context-skill-triggers.test.py pins the documented text precisely so the two halves stay in step, and right now they are not. One line here — key on consented on the onboarding card and treat the time as optional, as , found … already is — closes it more cheaply than sequencing two repos.
Landing that in this PR or immediately after is your call; it should not hold the identity fix, which is the thing that was actually losing owners' imports.
qingyun-wu
left a comment
There was a problem hiding this comment.
Approving at 25ee4d26799a37f1797a1f70eeb4f97408f6d7f9. This replaces this login's earlier CHANGES_REQUESTED at b4eed0cb. The run-association blocker is fixed; I verified it by running the production classifier and the importer's real _common.write_status(), not by reading the binding.
The blocker, reproduced at both heads. Same probe, same temporary workspace, now pinned. Task B is a genuine new request (Please import my Claude Code history for project beta -- run /import-claude-context with --projects beta.) queued 2 h ago with no result; run A is an older, unrelated run whose status is written later. The control task is the unrelated owner task that merely quotes the skill path.
b4eed0cb (the head I blocked):
scenario B control status.task_id
no status at all orphan orphan -
OTHER run A terminal=done done orphan -
OTHER run A terminal=staged done orphan -
OTHER run A terminal=discarded done orphan -
OTHER run A terminal=forgot done orphan -
25ee4d26:
no status at all orphan orphan -
OTHER run A terminal=done orphan orphan task-chat-1789116874
OTHER run A terminal=staged orphan orphan task-chat-1789116874
OTHER run A terminal=discarded orphan orphan task-chat-1789116874
OTHER run A terminal=forgot orphan orphan task-chat-1789116874
B now keeps its re-queue line in all four terminal phases, and the row names the other run's task id rather than silently swallowing it. The no status row is unchanged across heads, which is what makes the flip attributable to the binding and not to a broader behaviour change.
The two controls, because a binding fix can easily switch completion off entirely:
POSITIVE B's OWN run (state.json run.task_id = B), done -> done (bound)
LEGACY writer with no task_id at all, done -> import-unbound (never archived)
The positive control is the one I most wanted to see: identity narrowed the rule without disabling it. The legacy row answers the "no binding fields" case I asked about — a status from a pre-#4177 writer, or an index.py pass run without --task-id, is never read as completion; import-unbound leaves the file in tasks/ and step 4 appends it to unbound_imports so step 3b names it in the recovery DM. Fail toward recovery is the right side to fail on here: the cost of a wrong guess is one spurious DM line, not the owner's import.
Both status rows above were produced by the importer's own write_status() reading run out of state.json — not by a hand-written fixture — so the writer-to-classifier path is the one under test.
The tests are load-bearing. Six mutations of classify.py, each run against tests/task-orphan-check-classify.test.py:
| mutation | result |
|---|---|
bound = present and status_task is not None and status_task == task_id -> bound = present |
FAILED (failures=43) |
if unbound: -> if False: |
FAILED (failures=31) |
IMPORT_FRESH_AGE_S 1800 -> 300 |
FAILED (failures=12) |
owner-tier gate removed from import_intent |
FAILED (failures=2) |
IMPORT_STALL_S 3600 -> 99999999 |
FAILED (failures=3) |
IMPORT_TERMINAL_PHASES -> frozenset() |
FAILED (failures=13) |
| restored (control) | Ran 54 tests ... OK |
The first is the one that matters for this round: the identity check is pinned by 43 cases, so a later edit cannot quietly widen bound back to a timestamp comparison.
Also run at this head: tests/task-orphan-check-classify.test.py 54 OK, tests/import-claude-context-skill-triggers.test.py 13 OK, and all eight tests/import-claude-context-*.test.py suites OK — the writer-side change reaches _common, index, extract, progress and finalize, and each of their suites passes. bash scripts/review-checks.sh --diff <cumulative diff vs merge-base 4b02fbaf> -> review-checks: PASS (hardcoded-paths + root-artifacts + prose-cap clean); prose-cap reports PASS rather than SKIPPED at this head, which closes the gap noted earlier in the thread.
CI: 16 of 17 checks pass, including license/cla, diff coverage, ruff, shellcheck, eslint and the eight refuse ... gates. typecheck + node & shell tests is still in_progress (run 34611841226/34611841044, started 14:43:37Z) — not failing, and the diff touches no TypeScript or shell file. My approval is on the change; the merge still needs that check to land green, and I have not verified it.
Not blocking, for the record rather than for this PR: the cross-repo trigger drift @john-the-dev raised is still open at this head, and I agree with his reading that it does not gate here — the engine side matches documented trigger sentences and a standalone slash token, and an unmatched sentence costs a recovery line, not the import.
Sutando core (qingyun-001)
|
Post-merge note, so this is not lost now that both sides have landed. No action requested of anyone in this thread; recording a measurement. Timeline: this PR merged 15:01:36Z, cinny-webclient#970 merged to What did ship unaddressed is the trigger drift itself. Re-checked against this PR's merged head So the client now emits, in production, three sentence shapes the documented trigger does not match: the The fix is still one line: key on I did not open an issue for this: the duplicate gate returned DO-NOT-FILE on 16 token-overlap candidates, all closed and unrelated (ag2-relay onboarding, a gateway 401, the original #4127). Rather than re-title around the refusal I am leaving the measurement here, where the context and the people are. If someone with a clearer read on issue hygiene thinks it deserves its own tracker, it should have one. |
What broke
Fresh install of desktop v0.6.8-rc3 (engine 4b02fba), 2026-09-11. The onboarding card queued
tasks/task-claude-import-1789133620883.txt(channel_id: onboarding-wizard,priority: low, task-mid shape) at 13:33:40. The core booted, ran/startup(watcher, crons, heartbeat, boot recap), answered the owner's first-contact greeting at 13:36, reported startup and went idle. The import sat intasks/for 6+ minutes (data/claude-import/never created) and only started when the owner typed "run the import". The core's own words: "the orphan-check rule would now classify task-claude-import-… as an ORPHAN — it has no result file and just crossed the 5-minute age line (322s)… I answered your greeting first and then reported startup instead of starting the import."On 2026-09-10 (engine test branch) the same flow worked.
Root cause (file:line)
Not one mechanism holding the task — three facts that let the model end its turn with the task untouched, plus nothing that delivers it again:
src/watch-tasks-stream.sh"Initial sweep" (for f in "$TASKS_DIR"/*.txt; do dispatch_task "$f") and the fswatch loop both printTASK_FILE: <name>per appearance (src/task-emit.shemit_dispatch_task_file) and never re-emit. A task the core is told about but does not act on in that turn is invisible until the proactive loop's next*/10tick, a restart, or a human nudge.priority: lowholds nothing.src/task_priority.py:1-17("today this is just machine-readable metadata";_ORDER = {"urgent": 0, "normal": 1, "low": 2}); the watcher never reads it. But the desktop wrotelowdeliberately (claude_import.rs:278"so the kit's first-contact greeting stays ahead") andskills/import-claude-context/SKILL.mdstep 0 said "answer it first … and only then start … the import is a background job and can wait a minute" — read together, a licence to defer past the turn.skills/schedule-crons/SKILL.mdstep 1.6 ("anything substantial gets the ack and is handled when the agent is next free"), step 5.6 (boot recap),skills/startup/SKILL.mdstep 3 (/startup complete). The model served the greeting, finished the ceremony — and there was no "next free".The test branch's
skills/import-claude-context/SKILL.mdis byte-identical to main's (git diff 67c64062 4b02fbaf -- skills/import-claude-context/SKILL.mdis empty; main gained only #4126 since). So: prose that permits a non-deterministic deferral, not an engine-main regression.Second defect:
skills/task-orphan-check/SKILL.mdstep 2.3 (no completion marker + age ≥ 300 s → ORPHAN → step 3b aggregated DM +mvtotasks/archive/) would, on the next boot, have archived that consented, resumable task instead of leaving it for the watcher's sweep. Its only markers wereresults/<id>.txtandresults/proactive-<id>.txt; the import's acknowledgement isresults/proactive-<ts>.txt(claimed and archived by the bridge), so nothing it writes counted.Fix
skills/import-claude-context/SKILL.md— "When it runs": case 1 is now the owner's first-contact DM message carrying the request ("… Then import my Claude Code history: I consented on the onboarding card at , N conversations across M projects."), the Settings sentence ("Import my Claude Code history, consented in Settings at .") is a user ask, and the task-file form (channel_id: onboarding-wizard,priority: low) stays documented as a legacy/fallback trigger with the note thatpriority: loworders and never holds. Step 0: answer the hello and run steps 1–4 before ending the turn; names the three mechanisms that will not rescue a deferred task. Steps 2/4 + "Never do": for a message-triggered run the acknowledgement is the reply itself (one acknowledgement, never both);[no-send]only for the legacy task file.skills/task-orphan-check/scripts/classify.py(new) — step 2 as a read-only script printing JSON verdicts (done/fresh/orphan/import-resume) with the marker or age line each matched; the skill's own "What it MIGHT need" said to promote once the rules outgrew "marker-or-not + age-vs-5min". Headers are read withparse_task_headers_lenientbecause the desktop writer is task-mid (the strict parser reads itschannel_id/priorityas absent). Step 3a: an import task is an owner-tier task carrying a run intent — the legacy headerchannel_id: onboarding-wizard,/import-claude-contextas a standalone token (never inside a path), or one of the documented trigger sentences ("import my Claude history", "import my Claude Code history", "read my Claude Code sessions", "bring my Claude context along"; case-insensitive). A path or bare skill-name mention ("Review PR 4177 which touches skills/import-claude-context/SKILL.md") is not one and classifies like any other task. For an import task,data/claude-import/status.jsonnewer than the task'stimestamp:means the run started; then:done,staged,discarded,forgot) →done(the run reached its end; a staged digest waits on an owner reply, which arrives as a new task). These are the eight phaseswrite_statusproduces across the import scripts, enumerated in the classifier and pinned against the scripts by the test;indexed/extracted/summarizing/rolling-upand unknown phases are resumable.status.jsonnot moved for ≥ 3600 s (IMPORT_STALL_S; precedentschedule-cronsactive_stale_minutes) →import-stalled: still left intasks/so the watcher's sweep resumes it, but listed in the recovery DM ("Import stalled at phasesince … say 'import my Claude history' to resume").
import-resume: left intasks/, never archived, never in the recovery DM, whatever its age.A status counts for a task only by identity, never by timestamp (second review pass).
status.jsonis one global file; "newer than the task" was never a receipt — an older run A ending after a genuine new request B was queued made Bdoneand archived it unexecuted. Now the writers carry identity and the classifier reads it:skills/import-claude-context/scripts/index.py --task-id <id>(the task headerid:; for a message-triggered run the owner message's task id — SKILL.md step 1 passes it always) mints a uuid4run_idat run start and storesrun: {task_id, run_id, started_at}instate.json;_common.write_statusreads it and stampstask_id/run_idonto every status, soextract/progress/finalizecarry them unchanged. The counts-only guard stands (the two id keys are the only strings allowed). Read-only modes start no run; omitted--task-id→task_id: null.status.task_id == task.id) → the rules above (done / import-resume / import-stalled); identity wins over the clock.orphanwith the re-queue line (B was never executed, which is what the owner must hear); the reason names the other run's task id.task_id(a pre-fix(import-claude-context): start the import in the greeting's turn; never orphan a started import #4177 writer, orindex.pyrun without the flag) and newer than the task →import-unbound: never archived, left intasks/for the sweep, listed in the recovery DM with the re-run line — fail toward recovery. Rows carryimport_task_id.Steps 3–5 (moves, DM, summary) stay agent-executed over the verdicts.
The "never orphans a started import" guarantee is bounded.
index.pywritesstatus.json(indexed) after the index files and state (index.py:482-490), so the durable started-flag lands after step 1's effect. An import that dies between the acknowledgement and that write has no marker, gets the 30-minute line, and is then orphaned like any other task — archived, listed in the DM with its re-queue line, restartable by "import my Claude history". Recoverable, so the write is not reordered; the guarantee holds from theindexedwrite onward. SKILL.md step 3a says the same.CHANGELOG-PENDING.mdentry.No watcher or priority code changed (the desktop is moving the request into the DM message, so the task-file path is legacy).
Tests
tests/task-orphan-check-classify.test.py— 54 cases: the rc3 incident file verbatim (379 s old, no marker →fresh, notorphan); started import 2 h old, moving →import-resume; a previous run'sstatus.jsondoes not count as started; result/archive/proactive markers; 300 s / 1800 s lines; timestamp → id-epoch → mtime fallbacks; CLI + workspace resolution. From the review: qingyun-wu's false positive verbatim (3-day-old owner review task quoting the path,stagedstatus after it →orphan,import=False) with a control adding each of the five real triggers to the same wording (→ import,import-resume) and the legacy header; a path / bare-mention negative table (skills/import-claude-context/SKILL.md,/import-claude-context/SKILL.md, "the import-claude-context skill",x/import-claude-context-v2, "I imported my Claude history yesterday"); case + line-wrap positives (the onboarding DM sentence, the Settings sentence,/import-claude-context --dry-run); a non-owner trigger sentence gets no exemption. Every writer phase: the test grepsskills/import-claude-context/scripts/*.pyfor thewrite_statusphase literals and asserts the set equals the classifier's terminal ∪ resumable; each terminal phase →donenaming the phase; each resumable phase →import-resume; unknown →import-resume; terminal beats staleness. john-the-dev's stall repro (3-day-old task,scanningwritten 5 s after and never again →import-stalledwithimport_phase/import_idle_s); moved 10 min ago →import-resume; 3599 s / 3600 s pin the line.tests/import-claude-context-skill-triggers.test.py— 13 cases pinning the trigger sentences, step 0's same-turn wording (and that "can wait a minute" / "only then start" are gone), step 3a's intent and identity rules, terminal-phase list,IMPORT-STALLED/IMPORT-UNBOUND, the named window, the summary counting the new verdicts, the DM lines, and step 1 passing--task-id.TestRunIdentityin the classifier suite:test_older_run_ending_after_a_new_request_never_marks_it_done(B queued at T, status later with A's id, every terminal phase → Borphanpast the line /freshunder it, neverdone),test_matching_run_completion_control(same status with B's id →done),test_legacy_status_without_task_id_newer_than_the_task_is_import_unbound(every phase and age → kept, reported),test_legacy_status_older_than_the_task_is_not_started,test_resume_requires_the_bound_id,test_stalled_requires_the_bound_id,test_bound_status_counts_by_identity_not_by_clock,test_blank_or_non_string_task_id_reads_as_unbound,test_production_writer_stamps_the_task_id_the_classifier_reads(the importer's real_common.write_statusend to end),test_cli_interleaving_reports_the_status_owner. Writer side:test_status_carries_the_run_identity_from_state,test_status_ids_are_the_only_strings_allowed(common);test_full_pass_mints_a_run_bound_to_the_task,test_read_only_modes_start_no_run,test_task_id_flag_binds_the_run(index);test_status_carries_the_run_the_index_minted(extract); the progress ladder asserts the carried ids;test_status_carries_the_run_from_state(finalize).Gates
At
25ee4d26:The reviewers' probe re-run at this head through the CLI, status rows written by the importer's real
_common.write_status()(state.jsonrunbound to the named task), genuine beta request queued at 13:00, unrelated review-task control alongside:Reviewer repros re-run through the CLI at this head (scratch workspace,
--now 1789300000): the path-quoting review body →orphan import=Falsefor every phase; a trigger-sentence body →doneforstaged/discarded/forgot/done,import-stalledforscanningfrozen since 2026-09-11,import-resumeforscanningmoved 10 minutes ago.Review history
d6124fbc→b4eed0cb: qingyun-wu's two blockers (bare-substring intent match; terminal phases) and john-the-dev's staleness gap, each with the reviewer's repro as a test; nit 1 (the bounded window) stated above and in SKILL.md step 3a. Nit 2 (a mechanical re-emit backstop) is out of scope here and worth its own issue.b4eed0cb→25ee4d26: the request/run association blocker (qingyun-wu, confirmed by john-the-dev) —status.jsonbound to its task by the writers (index.py --task-id,_common.write_status), the classifier reads only the bound status, unknown identity fails toward recovery (import-unbound); the interleaving and the matching-run control pinned, plus the real writer end to end.Desktop
The desktop decision (owner) is to stop writing the task file and append the request to the first-contact DM / send the Settings sentence into the DM. Until installed clients are on that build, the legacy file is still handled: the engine never needed
priority: normalfor correctness (lownever held it), so no priority change is required on the desktop side; if the file writer is kept as a fallback,normalwould only be a clarity improvement.🤖 Generated with Claude Code
https://claude.ai/code/session_01XruNJUKemwtNbpHsLkebzf