Repository navigation
Commit 3196ef1
fix(pm): issue-transfer confirms a transfer on the target and reads a lagging old URL as a pending redirect (#21291)
Fixes #21020
Clause-②: no
## What changed
`scripts/pm/issue-transfer.mjs` confirmed a transfer by reading the
**source** URL first. If the old URL still answered 200, it exited 4
with "the card did not move". GitHub keeps serving a moved card's old
URL with 200 for minutes, so every measured transfer that landed
reported exit 4. The read-back now confirms on the **target**. The old
URL is printed beside the verdict and never decides it.
- **Finding the card on the target.** By number when one is in hand: the
number in the `transferIssue` answer (direct), or else the number the
old URL's 301 names. Otherwise by the pre-read's title, among the
target's cards updated since the transfer was sent. That is the relay
path: its run reports the number only in a job log and step summary that
a seat container cannot read. Pull requests are skipped. Two cards with
the same title are not a confirmation.
- **Exit 0 + the target URL** when the card answers from the target with
the pre-read's title. An old URL that still answers 200 from the source
is printed as a **PENDING REDIRECT**, never as "did not move". `--json`
gains `pending_redirect`.
- **Bounded re-read.** While the target answers without the card, it is
re-read on `TRANSFER_READ_BACK_DELAYS_MS` = 3 s, 7 s, 15 s (25 s in
all). Each re-read prints one line. The transfer is never re-sent. The
schedule copies the measured list lag of the relay's `issue_create`
read-back. The old URL's own lag is minutes, so the re-read does not
wait for it.
- **Exit 4 is kept for a board that truly disagrees.** That means: after
the last re-read the target still lacks the card, AND the old URL, read
again, still serves it from the source. Also exit 4: a 301 to another
repository or to another number than the mutation answered, or a card
with another title.
- **Exit 6 (UNCONFIRMED)** when the target cannot be read (for example
the cloud session gate's 403), when two cards share the title, or when
the target lacks the card but the old URL has moved since. An unreadable
target is not re-read.
- **Only a 301 supplies a number.** A 307 or 404 at the old URL is
printed as itself and never read as the move. New `redirectTarget()`
also reads the repository from the `/repos/OWNER/NAME/issues/N`
spelling.
`scripts/pm/fleet-write/dispatch.mjs` and `execute.mjs` are not touched,
and neither is `dispatch-gates.mjs`.
## Measurements (this session, 2026-10-02, read-only)
- **Mechanism assumption 1, confirmed.** At `c7396f1a`,
`issue-transfer.mjs` L407-411 sent a 200 from the old URL straight to
`EXIT_BOARD_DISAGREES`, before the target was ever read.
- **Assumption 2, confirmed.** The direct path held `moved.number` and
used it only to compare against the redirect's number.
- **The five transferred cards' old URLs, today:** all now answer
**301**, a day after the card measured four of them still answering 200.
- The four cards moved to hotcrm carry `Location:
https://api.github.com/repos/objectstack-ai/hotcrm/issues/1972` through
`1975`.
- objectstack#17163, moved to cloud (private), answers 301 with an
**empty** `Location` and an empty `url`, so no number is available to
this identity. That is the "301 naming no card" branch.
- **A cloud session cannot read a target repository that is not attached
to it.** `GET /repos/objectstack-ai/hotcrm/issues/1972` answers 403
"GitHub access to this repository is not enabled for this session". The
same happens:
- with or without a caller `Authorization` header (the proxy replaces
it: a bogus bearer still read objectstack#21020 with 200);
- on the github.com web page and the MCP issue read;
- GraphQL answers 403 "not available from Claude Code sessions", and
`/search/issues` also answers 403.
- So in such a seat the relay path ends at **exit 6** and names the
pending redirect. It no longer ends at exit 4. Exit 0 needs a readable
target: an attached repository, or the direct transport outside the
container.
- **NOT MEASURED: how a transfer sets the card's `created_at` /
`updated_at`.** The target was unreadable here. The title search lists
`sort=updated&since=SENT-minus-60s`. It rests on a transfer updating the
card, which is not yet measured: the transfer gives the card a new
number and a timeline event. It does not use `sort=created`, because a
transferred card is expected to keep its original creation date.
## Tests: on `4b37ce1e`
- `pnpm check:pm-issue-transfer` (= `node scripts/pm/issue-transfer.mjs
--self-test`) → exit 0: `✓ issue-transfer self-test: 63 cases pass
across 8 batteries`.
- Before this change it read 46 cases across 7 batteries.
- The roster gains the battery "the target decides …" (16 cases). The
read-back battery goes from 8 to 9 cases. The floor goes from 7 to 8.
- New pins:
- **The card's pin.** The old URL lags while the target holds the card ⇒
exit 0, with `to.url` equal to the target URL and `pendingRedirect:
true`, under both the direct and the relay transport.
- **Positive controls.** The target never holds the card and the old URL
is unchanged ⇒ still exit 4, after exactly 3 waits of 3000/7000/15000 ms
and 4 target reads. One mutation, or one dispatch with zero POSTs from
this process. Nothing re-sent.
- The 301 path is unchanged: the same call sequence as before.
- A lagging target is found on re-read 1 or 2.
- A 403 target is exit 6, with no wait and no "did not move".
- Two cards with the same title ⇒ exit 6.
- A 301 to another repository ⇒ exit 4, and the target is not read.
- An old URL that turned into a 301 after the window ⇒ exit 6, not 4.
- The two measured 301 shapes are pinned.
- **Reworded pins: the old-URL 404 and 307 cases.** The old rule "only
the 301 is read as the move" holds: neither status supplies a number.
With the target confirming the card, a 404 at the old URL is now exit 0,
printed as itself. A 307 under the relay never causes a read of the
number it names.
- **Ablations**, through `scripts/ablation-replace.mjs` in wrap mode.
Each mutation landed on disk (anchor 1 → 0, blob changed). Each was
restored to blob == HEAD `7cd9be392f56` with `git diff HEAD` empty.
- **M1** puts back the old "the old URL answers 200 ⇒ exit 4" behaviour
→ **12 of 63 red**, including both pins.
- **M2** turns off the re-read loop → **4 of 63 red**.
- **M4** drops the pull-request filter from the title search → **7 of 63
red**.
- The first M1 and M4 attempts were refused by the tool itself because
the replacement overlapped the anchor. The command never ran and nothing
was measured. They were re-run with distinct anchors.
- **Lint.** `npx eslint --no-inline-config --format json
scripts/pm/issue-transfer.mjs` → exit 0: 1 file, 0 errors, 0 warnings.
- The config eslint prints for this file has `parserOptions`
`{ecmaVersion: latest, sourceType: module}`. No type-aware linting is
set up, so this diff cannot change any verdict on an untouched file.
- The repo-wide `pnpm lint` is left to CI.
- **Gates.** `node scripts/pm/dispatch-gates.mjs --repo
objectstack-ai/objectstack --commands` on this tree derived 28 families.
All 28 ran with exit 0.
- Reconciliation via `--ran` (exit codes recorded): "28 derived
famil(ies) accounted for — 28 run, 0 NOT-MEASURED (a DERIVED zero …)".
- The extra run is `check:pm-issue-transfer`, the script's own suite.
- **No package is touched,** so there is no build closure and no package
test or typecheck. `check:nul-bytes` is OK, and a control-byte self-scan
of the file found nothing.
## Acceptance notes
- **CI never runs `check:pm-issue-transfer`.** No workflow names it, and
`check:self-test-wired` covers only scripts CI already runs. The local
run above is this suite's only measurement. Carrier: none.
- **Under the relay, exit 0 in a cloud seat requires the target to be
attached to the session** (see Measurements). If the relay reported the
new number somewhere a seat container can read, such as an annotation,
the relay path could name the target URL even then. That is a change to
what the relay emits, and it is left to the PM or maintainer. PR #21278
lands on `dispatch.mjs` and is not touched here.
- **NOT MEASURED:** the `updated_at` premise of the title search
(above). The first relay transfer from a session that can read the
target settles it. Its read-back line says "found by that title" when
the premise holds.
---
_Generated by [Claude
Code](https://claude.ai/code/session_01FNKm1SmPpuJASnbjxWGtsJ)_
Co-authored-by: Claude <noreply@anthropic.com>1 parent 5a9292e commit 3196ef1
1 file changed
Lines changed: 325 additions & 68 deletions
0 commit comments