Skip to content

fix(import-claude-context): start the import in the greeting's turn; never orphan a started import - #4177

Merged
bassilkhilo-ag2 merged 4 commits into
mainfrom
fix/import-task-pickup
Sep 11, 2026
Merged

bassilkhilo-ag2 merged 4 commits into
mainfrom
fix/import-task-pickup

Conversation

@bassilkhilo-ag2

@bassilkhilo-ag2 bassilkhilo-ag2 commented Sep 11, 2026 •

Copy link
Copy Markdown
Collaborator

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 in tasks/ 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:

  1. The watcher emits a task once. src/watch-tasks-stream.sh "Initial sweep" (for f in "$TASKS_DIR"/*.txt; do dispatch_task "$f") and the fswatch loop both print TASK_FILE: <name> per appearance (src/task-emit.sh emit_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 */10 tick, a restart, or a human nudge.
  2. priority: low holds 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 wrote low deliberately (claude_import.rs:278 "so the kit's first-contact greeting stays ahead") and skills/import-claude-context/SKILL.md step 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.
  3. The ceremony gives the turn other things to finish. skills/schedule-crons/SKILL.md step 1.6 ("anything substantial gets the ack and is handled when the agent is next free"), step 5.6 (boot recap), skills/startup/SKILL.md step 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.md is byte-identical to main's (git diff 67c64062 4b02fbaf -- skills/import-claude-context/SKILL.md is 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.md step 2.3 (no completion marker + age ≥ 300 s → ORPHAN → step 3b aggregated DM + mv to tasks/archive/) would, on the next boot, have archived that consented, resumable task instead of leaving it for the watcher's sweep. Its only markers were results/<id>.txt and results/proactive-<id>.txt; the import's acknowledgement is results/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 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 + "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 with parse_task_headers_lenient because the desktop writer is task-mid (the strict parser reads its channel_id/priority as absent). Step 3a: an import task is an owner-tier task carrying a run intent — the legacy header channel_id: onboarding-wizard, /import-claude-context as 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.json newer than the task's timestamp: means the run started; then:

    • terminal phase (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 phases write_status produces across the import scripts, enumerated in the classifier and pinned against the scripts by the test; indexed / extracted / summarizing / rolling-up and unknown phases are resumable.
    • resumable phase, status.json not moved for ≥ 3600 s (IMPORT_STALL_S; precedent schedule-crons active_stale_minutes) → import-stalled: still left in tasks/ so the watcher's sweep resumes it, but listed in the recovery DM ("Import stalled at phase

      since … say 'import my Claude history' to resume").

    • resumable phase, moved within the hour → import-resume: left in tasks/, never archived, never in the recovery DM, whatever its age.
    • not started → 30-minute line instead of 5, then orphan like any other task.

    A status counts for a task only by identity, never by timestamp (second review pass). status.json is 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 B done and 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 header id:; for a message-triggered run the owner message's task id — SKILL.md step 1 passes it always) mints a uuid4 run_id at run start and stores run: {task_id, run_id, started_at} in state.json; _common.write_status reads it and stamps task_id / run_id onto every status, so extract / progress / finalize carry 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.
    • bound (status.task_id == task.id) → the rules above (done / import-resume / import-stalled); identity wins over the clock.
    • bound to a different task → the same as absent for this task: fresh under 30 min, else orphan with the re-queue line (B was never executed, which is what the owner must hear); the reason names the other run's task id.
    • no task_id (a pre-fix(import-claude-context): start the import in the greeting's turn; never orphan a started import #4177 writer, or index.py run without the flag) and newer than the task → import-unbound: never archived, left in tasks/ for the sweep, listed in the recovery DM with the re-run line — fail toward recovery. Rows carry import_task_id.

    Steps 3–5 (moves, DM, summary) stay agent-executed over the verdicts.

    The "never orphans a started import" guarantee is bounded. index.py writes status.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 the indexed write onward. SKILL.md step 3a says the same.

  • 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).

Tests

  • tests/task-orphan-check-classify.test.py — 54 cases: the rc3 incident file verbatim (379 s old, no marker → fresh, not orphan); started import 2 h old, moving → import-resume; a previous run's status.json does 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, staged status 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 greps skills/import-claude-context/scripts/*.py for the write_status phase literals and asserts the set equals the classifier's terminal ∪ resumable; each terminal phase → done naming the phase; each resumable phase → import-resume; unknown → import-resume; terminal beats staleness. john-the-dev's stall repro (3-day-old task, scanning written 5 s after and never again → import-stalled with import_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.
  • Identity (second pass), TestRunIdentity in 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 → B orphan past the line / fresh under it, never done), 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_status end 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:

tests/task-orphan-check-classify.test.py            Ran 54 tests  OK
tests/import-claude-context-skill-triggers.test.py  Ran 13 tests  OK
import-claude-context-common 12 / index 24 / extract 18 / progress 3 / finalize 39 / stage 54 / prompts 3   OK
sending-suffix-is-proactive-only 6/6; startup-ceremony-gate 10  OK
scripts/lint-skill.py --all --strict                55 manifest(s), 0 error(s), 0 warning(s)
ruff check (changed .py files)                      All checks passed!
scripts/review-checks.sh --diff <(git diff origin/main)   PASS (hardcoded-paths + root-artifacts + prose-cap clean)
diff-cover vs origin/main                           100% — _common.py, index.py, classify.py (173 lines, 0 missing) ≥ 95

The reviewers' probe re-run at this head through the CLI, status rows written by the importer's real _common.write_status() (state.json run bound to the named task), genuine beta request queued at 13:00, unrelated review-task control alongside:

later global status                         genuine beta import request   review-task control
absent                                      orphan                        orphan
done / staged / discarded, run of task A    orphan  (import_task_id = A)  orphan
done / staged / discarded, run of task B    done    (import_task_id = B)  orphan
done / staged / discarded, no task id       import-unbound (kept, in DM)  orphan

Reviewer repros re-run through the CLI at this head (scratch workspace, --now 1789300000): the path-quoting review body → orphan import=False for every phase; a trigger-sentence body → done for staged / discarded / forgot / done, import-stalled for scanning frozen since 2026-09-11, import-resume for scanning moved 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.json bound 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: normal for correctness (low never held it), so no priority change is required on the desktop side; if the file writer is kept as a fallback, normal would only be a clarity improvement.

🤖 Generated with Claude Code

https://claude.ai/code/session_01XruNJUKemwtNbpHsLkebzf

…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
@github-actions

github-actions Bot commented Sep 11, 2026 •

Copy link
Copy Markdown
Contributor

Coverage Gate

✅ Diff coverage PASSES the 95% bar. Whole-tree (informational): 90%.

Diff Coverage

Diff: origin/main...HEAD, staged and unstaged changes

  • skills/import-claude-context/scripts/_common.py (100%)
  • skills/import-claude-context/scripts/index.py (100%)
  • skills/task-orphan-check/scripts/classify.py (100%)

Summary

  • Total: 203 lines
  • Missing: 0 lines
  • Coverage: 100%

…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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

  1. The never-orphan claim is bounded, and the PR does not say where. index.py calls write_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.
  2. 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.py pins 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 john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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, and parse_task_headers_lenient all exist and the parser returns .headers/.body as classify.py expects.
  • REPO = SCRIPT_DIR.parents[2] lands on the repo root for skills/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.

@john-the-dev

Copy link
Copy Markdown
Collaborator

Correction to my review above, on its own provenance.

The body says "Reviewed at head d6124fbc". That is where the PR was when I hit submit, but the diff I actually read was fetched at 757e71a0 — the head moved while I was reviewing, and I stamped the fresh sha onto a review of the older tree. So as posted, the approval attested to one commit I had not read.

I have now read it. 757e71a0..d6124fbc is one commit, docs-only, skills/import-claude-context/SKILL.md +8/-6: cases 1 and 2 swap (owner ask becomes the primary trigger, the AG2 Space card becomes one route to it) plus a paragraph stating the skill is engine-native and needs nothing from AG2 Space. I checked the renumbering rather than assuming it, since a swap like this breaks by leaving a stale cross-reference: step 0's "(case 2)", step 2's "after the hello in case 2", step 4's "case 2: the hello … case 1: the acknowledgement", the background-job paragraph, and "a standalone Sutando has cases 1 and 3" are each consistent with the new numbering. --run-kind user stays with the owner ask and --run-kind onboarding with the card. No defect.

classify.py and both test files are untouched by that commit, so every finding in my review — including the import-resume staleness gap and its 3-day repro — applies unchanged at d6124fbc. The approval stands at d6124fbc, and now honestly.

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 john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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
@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

@qingyun-wu @john-the-dev — addressed at b4eed0cb (one commit on top of d6124fbc; classify.py, its test, task-orphan-check/SKILL.md, one sentence in import-claude-context/SKILL.md, the triggers test, the changelog line). Per finding:

B1 — bare import-claude-context substring parks unrelated owner tasks (qingyun, confirmed by john). is_import_task is now import_intent, matching a run intent only: the legacy header channel_id: onboarding-wizard; /import-claude-context as a standalone token — (?<![\w/.\-])/import-claude-context(?![\w/\-]), so neither skills/import-claude-context/SKILL.md nor /import-claude-context/SKILL.md matches; or one of the four documented trigger sentences, case-insensitive with any whitespace between words ("import my Claude history", "import my Claude Code history" — which the onboarding DM sentence and the Settings sentence both contain — "read my Claude Code sessions", "bring my Claude context along"). The matched intent is reported on the row as import_intent. Your repro is test_reviewer_false_positive_owner_task_quoting_the_path_is_an_orphan (3-day-old owner review task, staged status after it → orphan, import=False, like main); the control test_same_review_body_with_each_real_trigger_is_an_import adds each of the five triggers to the same wording (→ import, import-resume) and the header form; a negative table covers paths, bare mentions, x/import-claude-context-v2, "I imported my Claude history yesterday"; a positive table covers case and line-wrap. CLI at this head: the review body → orphan import=False for every phase.

B2 — import-resume blind to terminal phases (qingyun, confirmed by john). Enumerated every write_status phase across the import scripts: indexed (index.py), extracted (extract.py), summarizing / rolling-up (progress.py), staged (progress.py, finalize.py stage and partial commit), done (finalize.py commit), discarded, forgot. Terminal = done, staged, discarded, forgot → verdict done with the phase named in the reason; staged is terminal on the reasoning that the digest has been posted and the next step is an owner reply, which arrives as a new task. Resumable = the other four plus anything unknown. test_every_writer_phase_is_classified greps skills/import-claude-context/scripts/*.py for the phase literals and asserts the set equals the classifier's terminal ∪ resumable (and that they are disjoint), so a new writer phase fails the suite until it is placed. Each phase is pinned individually. CLI: trigger body + discarded → done.

B3 — no recency bound on started (john). Third branch import-stalled: started, resumable phase, status.json not moved for IMPORT_STALL_S = 3600 (precedent schedule-crons active_stale_minutes 60). The task stays in tasks/ so the sweep still resumes it; the row carries import_phase / import_idle_s, and SKILL.md step 3b now lists it in the recovery DM ("Import stalled at phase

since … say 'import my Claude history' to resume it now, or /import-claude-context --discard"); a stalled import alone earns the DM. Your repro is test_started_import_frozen_for_three_days_is_stalled (3-day-old task, scanning written 5 s after and never again → import-stalled); moved 10 min ago → import-resume; 3599 s / 3600 s pin the line; terminal beats staleness. The earlier "resume whatever its age" case now uses a status that moved 10 minutes ago, which is the property it meant to pin.

Nit 1 — the bounded window. Stated in SKILL.md step 3a and the PR body: index.py writes status.json (indexed) after the index files and state (index.py:482-490), so an import that dies before that write has no marker, gets the 30-minute line, and orphans recoverably (archived + re-queue line). The guarantee holds from the indexed write onward; the write is not reordered.

Nit 2 (mechanical re-emit backstop) — agreed, out of scope here; worth its own issue.

Gates at b4eed0cb: task-orphan-check-classify 44 OK (was 30), import-claude-context-skill-triggers 10 OK (was 9), adjacent import/startup/sending-suffix suites OK; review-checks.sh --diff PASS; lint-skill --all --strict 55/0/0; ruff clean; diff-cover vs origin/main 100% on classify.py (124 lines — the unused is_import_task wrapper is gone and the stat-OSError branch you saw uncovered is now exercised).

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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-stalled keeps the task in tasks/ while still reporting it. Both halves of the tension — resumption survives, and the owner stops being the last to know. Taking the active_stale_minutes precedent 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.

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

Cross-check from ag2-space/cinny-webclient#970 (head 30e319aa) — the client-side sentences the engine parses have changed in two ways since the review's contract note:

  1. The Settings sentence now carries counts when the scan has them: Import my Claude Code history, consented in Settings at <ISO>, found N conversations across M projects. (the , found … clause is optional, same as the onboarding form).
  2. A consent time is never invented. When the client has no recorded time, every form says (time not recorded) in place of at <ISO> — e.g. Then import my Claude Code history: I consented on the onboarding card (time not recorded), found N conversations across M projects. Please file that as "consent time unknown", not as a timestamp.
  3. A third, standalone form for an rc3 install whose greeting already went out without the request (sent once on a signed-in mount, not appended to anything): Import my Claude Code history, consented on the onboarding card at <ISO>, found N conversations across M projects. — or with (time not recorded).

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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

@qingyun-wu @john-the-dev Live witness for the greeting → core hand-off (the round trip the review asked for), desktop build with cinny e863268f + engine 757e71a0 on a fresh onboarding in a Tart VM, 2026-09-11 (times UTC):

  • 14:10:45 — owner clicked Import on the card (consent recorded, counts 47/4).
  • 14:11 — the client sent the first-contact DM message: "Hi — I'm all set up, say hello. Then import my Claude Code history: I consented on the onboarding card at 2026-09-11T14:10:45.434Z, found 47 conversations across 4 projects." — the app showed it as picked up.
  • 14:14:35 — data/claude-import/status.json phase indexed (47 sessions, 42 conversations, 5 empty) — the core started the import in the same turn, no task file, nothing typed.
  • 14:15:20 — the core's reply to that message (results/archive/task-545ad690…): the hello, then "Importing your Claude Code history: 42 conversations across 4 projects (5 empty ones skipped). I'll message you here when it's ready to review — usually 10–20 minutes. …"
  • 14:25:59 — phase summarizing, 37 of 42 summaries done; tasks/ holds no import task (only the wake-briefing).
    Same flow on rc3 (task-file queue) stalled with the task untouched for 6+ minutes until the owner typed "run the import".

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

@john-the-dev

Copy link
Copy Markdown
Collaborator

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 30e319aa, which fixes the fabricated consent timestamp @qingyun-wu flagged there: when claudeImportConsentAt is missing the client now emits an explicit (time not recorded) instead of substituting now(). That is the right call — it is the same "never invent a measurement" principle this PR applies to phases.

But it moves the emitted text out from under this skill's documented trigger. I pulled the literal sentences from that PR's importContext.smoke.ts and matched them against the trigger parsed from skills/import-claude-context/SKILL.md at b4eed0cb (A = "import my Claude Code history" AND B = "consented on the onboarding card at <ISO>"):

variant                        SKILL.md trigger   classify.py intent
onboarding  + time + counts    FIRES              True
onboarding  NO TIME            NO                 True
late        + time + counts    NO                 True
late        NO TIME            NO                 True
  1. (time not recorded) removes the literal at, which B requires. So precisely the records that motivated the client fix — a consent with no recorded time — produce a greeting the documented trigger does not match. Suggest keying on consented on the onboarding card and treating the time as optional, the way the , found … clause already is.
  2. The late standalone shape is undocumented here. Import my Claude Code history, consented on the onboarding card at <ISO> is a third surface (a card consent whose greeting already went out), alongside the onboarding and Settings forms. IMPORT_TRIGGER_SENTENCES in classify.py matches it case-insensitively so orphan-check classifies it right, but SKILL.md has no entry and tests/import-claude-context-skill-triggers.test.py pins the documented text — so the pins and the client will drift apart.

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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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

Copy link
Copy Markdown
Contributor

@cla-assistant check

@bassilkhilo-ag2

Copy link
Copy Markdown
Collaborator Author

@qingyun-wu @john-the-dev — addressed at 25ee4d26 (one commit on top of b4eed0cb). Per finding:

[P2] classify.py:207-218 — a global status.json timestamp is not a receipt for THIS task (qingyun, confirmed by john). Fixed by giving the status an identity and reading only that.

Writers (skills/import-claude-context/scripts/): index.py --task-id <id> — the task header id:, or for a message-triggered run the owner message's task id; SKILL.md step 1 now passes it always — mints a uuid4 run_id at run start and stores run: {task_id, run_id, started_at} in state.json. _common.write_status reads that run and stamps task_id / run_id onto every status, so extract / progress / finalize carry them without a change to their call sites. The counts-only guard stands: the two id keys are the only strings it accepts (a string or None); a title, a path, or a non-string id is still refused. --dry-run / --counts-only start no run; an omitted --task-id yields task_id: null.

Classifier: a status counts for a task only when status.task_id == task.id. Bound → the b4eed0c rules (terminal → done, resumable + fresh → import-resume, stalled → import-stalled); identity wins over the clock. Bound to a different task → for this task the same as absent: fresh under 30 min, else orphan with the re-queue line, the reason naming the other run's task id — request B was never executed, which is what the owner must hear. No task_id (a pre-#4177 writer, or index.py run without the flag) and newer than the task → new verdict import-unbound: never archived, left in tasks/ for the sweep, listed in the recovery DM ("an import run started … but cannot be matched to this request … say 'import my Claude history' to re-run it"). Fail toward recovery, as you both put it: a spurious DM line costs a line, a wrong archive costs the import. Rows carry import_task_id.

Your probe at this head, status rows written by the real _common.write_status() with state.json's run bound to the named task, beta request queued at 13:00, review-task control alongside:

later global status                         genuine beta import request   review-task control
absent                                      orphan                        orphan
done / staged / discarded, run of task A    orphan  (import_task_id = A)  orphan
done / staged / discarded, run of task B    done    (import_task_id = B)  orphan
done / staged / discarded, no task id       import-unbound (kept, in DM)  orphan

Tests — tests/task-orphan-check-classify.test.py (44 → 54), new class TestRunIdentity: test_older_run_ending_after_a_new_request_never_marks_it_done (the interleaving for every terminal phase: B orphan past the 30-minute line, fresh under it, never done), 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; counts), 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 (state.json run → the importer's real write_status → classifier: B → done, A → orphan, none → import-unbound), test_cli_interleaving_reports_the_status_owner. Every earlier "started" case now binds the status to its task. Writer side: test_status_carries_the_run_identity_from_state and 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). Trigger-text pins (10 → 13): step 3a's identity rule, IMPORT-UNBOUND, the DM line, step 1's --task-id.

Prose: task-orphan-check/SKILL.md step 2 fields, step 3a rewritten around the identity rule with your interleaving named, step 4 outcome, step 3b skip condition / DM line / not-moved rule, step 5 summary (M+K+I+S+U+J), iteration log v0.1.7; import-claude-context/SKILL.md step 1, the status paragraph, the --task-id flag row, the files-table row; the changelog line.

Gates at 25ee4d26: classifier 54 OK, skill-triggers 13 OK, import-claude-context common 12 / index 24 / extract 18 / progress 3 / finalize 39 / stage 54 / prompts 3 OK, sending-suffix 6/6, startup-ceremony-gate 10 OK; review-checks.sh --diff PASS; lint-skill --all --strict 55/0/0; ruff clean; diff-cover vs origin/main 100% across _common.py, index.py, classify.py (173 lines, 0 missing).

@john-the-dev john-the-dev left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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 qingyun-wu left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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)

@bassilkhilo-ag2
bassilkhilo-ag2 merged commit cbd3438 into main Sep 11, 2026
18 checks passed
@bassilkhilo-ag2
bassilkhilo-ag2 deleted the fix/import-task-pickup branch September 11, 2026 15:01
@john-the-dev

Copy link
Copy Markdown
Collaborator

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 dev 15:01:43Z, and #971 promoted it to main at 15:10:11Z. The ordering worked out — the engine landed first — so the hazard I raised about sequencing did not bite.

What did ship unaddressed is the trigger drift itself. Re-checked against this PR's merged head 25ee4d26, fetched now rather than recalled:

no-time greeting fires documented trigger : False
"(time not recorded)" mentioned in SKILL.md: False
'late' standalone form documented          : NO

So the client now emits, in production, three sentence shapes the documented trigger does not match: the (time not recorded) onboarding form and both late forms. The practical effect is bounded — the core reads prose rather than matching a regex, and classify.py's IMPORT_TRIGGER_SENTENCES already matches case-insensitively, so the likely failure is an onboarding run handled as a plain owner ask (--run-kind user) rather than a dropped import. But the population it lands on is exactly the rc3-residual installs the late-send was built to serve, and tests/import-claude-context-skill-triggers.test.py pins the documented text precisely so the two halves cannot drift.

The fix is still one line: key on consented on the onboarding card and treat the time as optional, the way , found … already is, plus an entry for the late shape.

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.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants