Repository navigation
Conversation
|
Codecov Report❌ Patch coverage is
📢 Thoughts on this report? Let us know! |
79d2d70 to
223f347
Compare
a9c3a8f to
be482ef
Compare
| } | ||
| // Given up on earlier, and recorded on disk so this holds across sessions: a step | ||
| // that could apply later must never land AFTER a step that comes later in the plan, | ||
| // because applyChanges copies whole after/ files and would overwrite that step's |
There was a problem hiding this comment.
🟡 A later Merge Tests step still applies after an earlier one is given up, and its .sln names a project that is not on disk.
The in-loop stop and the persisted skip list stop a given-up step from landing out of order. But after step A goes into skipped, the loop continues, so a later run B on the same card still applies. applyChanges copies whole after/ files, and the agent's .sln is the server's whole solution, which already holds run A's test-project entry. Run A's project files never landed locally.
if (state.skipped.includes(step.StepId)) {
continue
}Why it matters: Run A is given up → run B overwrites the local .sln with the server's copy, which references A's test .csproj → that project path does not exist locally → Visual Studio shows the project as unavailable and dotnet build on the solution fails with 'project file not found'. The customer is left with a broken solution and only a 'could not add tests' worklog line.
Suggestion: Once any earlier step on the card is in skipped, stop applying later steps too: keep the break semantics for skipped steps, and tell the customer that later test runs are blocked. Or, before applying B, strip Project stanzas whose paths do not exist locally from B's after/.sln. Add a test with a skipped A and a succeeded B.
[code-review-sage]
| continue | ||
| } | ||
| // Still not applied. The artifact may not be uploaded yet, so retry on later polls | ||
| // — but stop here rather than applying anything after it, for the ordering reason |
There was a problem hiding this comment.
🟡 Five failed polls retire a step for good, on disk and across restarts, with no way to retry.
Every poll that leaves the step unapplied counts against the bound. That includes a listArtifactsForStep result that comes back empty because of throttling, which the description says this service does, and a network blip. After 5 such polls the step is written to beam-apply-state.json and is never tried again, even after a Visual Studio restart. Nothing resets it short of deleting the file by hand. The customer message says the tests are 'still available from the job's artifacts', but the IDE gives them no way to get there.
if (polls >= this._beamGiveUpPolls) {
state.skipped.push(step.StepId)
this.writeBeamApplyState(solutionRootPath, jobId, state)Why it matters: About a minute of throttling or an offline period right after the step succeeds → 5 counted polls → the step is permanently skipped → the customer never gets the tests they asked for, and has no recovery path except generating a new run.
Suggestion: Count only polls where the artifact was actually found and the apply failed. Do not count an empty or errored listing, or count it against a much larger bound. Also let a new session (or a clearly signalled retry) clear skipped, or expire entries after a time window. Make the give-up message tell the customer what they can do next.
[code-review-sage]
Rebuilt from the branch base. The beam apply is now 340 lines against 1113, and reuses the mechanism the normal IDE flow already uses instead of reimplementing it. The transform agent already does everything the IDE flow needs: it uploads a CUSTOMER_OUTPUT diff tagged with the step id and marks the step SUCCEEDED, "independent of any checkpoint-review config". The IDE flow picks that up in downloadCompletedStepArtifacts, which selects SUCCEEDED steps at plan DEPTH 2. A beam job is two levels deeper, because the orchestrator inserts "(Beamed) Batch" and a card per repo, so the depth rule never matches — and the whole pass is skipped for a beam besides. Those two facts are the entire reason any beam code exists here. So: find the loaded repo's beam card, take the SUCCEEDED "Merge Tests" steps under it, and hand each to the existing downloadCompletedStepArtifact, which downloads, retries, applies and records the step exactly as it does for an IDE-started job. Scoping is simpler than before. One beam card in the plan is unambiguous and needs no scope at all, which is the common case. With several, the IDE's scope picks the repo, accepting either the card's own StepId or any step beneath it — so the feature no longer depends on which of the two the toolkit sends, a contract that is still unverified end to end. If the scope cannot identify exactly one card, nothing is applied, because the wrong repo's tests landing in this solution is worse than no tests. Local edits are NOT preserved, deliberately. A beam job never passed through startTransform, so it has no source-files.json and applyChanges has nothing to protect against. Instead every file the beam apply would overwrite is copied to conflict-backups first and named in a log line telling the customer to merge their own changes back. A backup that cannot be taken keeps the file and reports a conflict rather than overwriting it, and an existing backup is never replaced, so the first copy — the customer's own — always survives. This means a project the customer removed from their .sln locally comes back, since the agent's .sln is the server's whole solution. Accepted: it is backed up and reported, and the alternative is a structural .sln merge, which is a much larger change for a rarer case. A true three-way merge is the follow-up, using the beam-artifact.zip that downloadBeamArtifact already leaves next to the extracted repo. A step that has not applied stops the loop rather than letting a later one land first: applyChanges copies whole after/ files, so an earlier diff landing after a later one would overwrite the later one's .sln. A step still unapplied after five polls is left alone for the session, so it cannot re-list artifacts and retry its download on every poll forever. Counted in polls, not elapsed time, so a laptop sleep cannot spend the budget. The only change to the shared apply path is one optional boolean. 20 tests added; 578 pass. Re-ordering the plan-order stop fails exactly its own test. tsc, prettier and ESLint clean.
…its state Five review findings, all verified against the code first. The customer could only learn what happened by reading the LSP output log, which Visual Studio users do not open. That left the PR's own problem — a green plan and no tests, with nothing saying so — alive on every error path. Outcomes now go to the job's worklog, which the IDE renders in the activity feed: tests added, files overwritten and where their previous version is, a step given up on, and a scope that cannot say which beamed repo this solution is. Deliberately NOT _currentDiffContext: that becomes DiffApplyFailed, which the local-build loop reads as a failed apply and would stall on. The retry count and give-up list were in-memory, and getTransformInfo calls clearJobCache on every poll of a COMPLETED job immediately before fetching the plan — so the count never reached its bound and the step re-listed artifacts and re-ran three download retries forever. Worse, a given-up step is not in checkpoints-applied.json, so a later session would retry it and could apply it AFTER a step that comes later in the plan, overwriting that step's .sln and dropping its test project. Both now live in beam-apply-state.json beside checkpoints-applied.json, so they survive clearJobCache and a VS restart. A file kept because its backup failed still let the step be recorded as applied: the diff half-landed, the step retired, and nothing retried it, so the solution never got the test project. applyChanges now reports backupFailed and downloadDiffArtifact skips saveAppliedCheckpoint when it is set, leaving the existing retry and give-up to handle it. The never-replace backup rule was put in the SHARED backupConflictedFile, so non-beam conflicts silently started accumulating numbered copies with the oldest version under the bare name — contradicting this PR's claim that non-beam paths are untouched. It is now behind a preserveExisting flag that only the beam apply passes. The JSDoc that had been orphaned above the new helper is back on backupConflictedFile and documents the flag. Tests: 11 added, 589 pass. The backup-before-delete and backup-before-move branches had no coverage at all and now do, including their backup-failure paths; the non-beam replace-in-place behaviour is pinned; the apply state's round-trip to disk is tested for real, not just through the stub the plan tests use. Letting the checkpoint save run on a failed backup fails exactly its own test. Also fixes a test bug of mine: the beam plan tests used a fake Windows solution path, so persisting state created a literal "C:" directory inside the package. They stub the state helpers now and touch no disk.
Two review findings, both verified against the code first. A given-up step used to `continue`, so the next test run on the same card still applied. The agent's .sln is the server's WHOLE solution, so that later run's .sln already names the given-up run's test project — and that project's files never landed locally. The customer would get a solution Visual Studio shows as unavailable and `dotnet build` fails on with 'project file not found', plus a worklog line saying only that one run could not be added. A skipped step now stops the card: nothing after it is applied, the give-up message says later runs are blocked too, and later polls stay quiet rather than repeating it. The give-up bound counted every poll that left the step unapplied, including a listing that came back empty or errored — throttling, an offline laptop, or just the seconds right after the step is marked SUCCEEDED, before the upload lands. Five of those retired the step permanently, on disk, across restarts, with no way back. downloadCompletedStepArtifact now returns whether an artifact was there at all, and only a poll that HAD one and still failed to apply it counts. A missing artifact logs and waits, as it should. listArtifactsForStep returns null instead of throwing, so anything thrown after it is a real apply failure and does count. Tests: 3 added, 591 pass. The cross-session test now asserts the stronger invariant — neither the given-up step nor anything after it ever applies. Turning the stop back into a `continue`, or counting a missing artifact against the bound, each fails exactly its own test.
…he root Four review findings, all verified against the code first. The give-up bound still counted failures that say nothing about whether the diff can apply: a throttled CreateArtifactDownloadUrl, a dropped S3 download, a bad archive. All three return '' from downloadDiffArtifact after its own 3 attempts, and the beam path counted that as a failed apply. Sustained throttling across 5 polls retired the step for good, on disk, across restarts — and now also blocks every later test run on the card. downloadDiffArtifact reports a three-way outcome instead: transfer-failed (the diff never arrived), apply-failed (it was on disk and writing it did not fully work), applied. Only apply-failed counts. The one non-beam caller takes .path and is otherwise unchanged. applyChanges writes file by file, so a diff that failed part way through has already put some of its files on disk — a backup failure keeps one file and writes the rest, which is the main path that spends the bound. The give-up message claimed 'your solution has not been changed', and a test pinned that wording. It now says the tests could not be FINISHED, that some files may already have been updated, and where conflict-backups is. Nothing checked that the diff was computed against the root we write into. Every path in metadata.json is relative, and on a beam the solution root comes from the toolkit rather than from an upload by this LSP. filesUpdated and filesRemoved name files that must already be there, so if none of them exists under the root, the diff belongs to a different tree: the beam apply now refuses before writing anything, rather than scattering a test project and a .sln over unrelated directories. Beam-only, and skipped when the diff only adds files, where there is nothing to check. The rename detector matched on name AND status, so a FAILED or SKIPPED Merge Tests step — what a run that generated no tests leaves behind — was reported to the customer as a renamed step to go and report. It also only ran when the card yielded no steps at all, so a rename landing mid-job, after an earlier run matched, went unnoticed: exactly the case it exists for. It now matches on the name alone, runs for every unit test run on the card, and records what it has reported in the apply state so the customer is told once rather than every poll. Tests: 7 added, 598 pass. The three downloadDiffArtifact retry tests now assert which half failed. Three mutations each fail exactly their own test: counting a transfer failure against the bound, dropping the root check, and putting the status back into the rename check.
…alarm Four review findings, all verified against the code first. A diff that never ARRIVES was excluded from the give-up bound and never reported. That is right for an upload still in flight, throttling or a sleeping laptop, but a never-uploaded artifact and a corrupt archive do not clear up: the card stopped at that step for the rest of the job, re-listing and re-downloading on every poll against a throttling service, while the customer saw green steps and no tests — the silent failure this PR exists to remove. Those polls now have their own, far looser bound (30 vs 5) in a separate `waits` counter in beam-apply-state.json, and tripping it writes one worklog line saying the tests could not be downloaded. applyChanges catches every per-file copy, unlink and mkdir error, logs it and still returns success — right for a large IDE transform diff, wrong for a Merge Tests diff that is one test project plus the .sln, where a single failed write leaves the solution naming a .csproj that is not on disk. A file locked by Visual Studio or a virus scanner therefore recorded the step as applied and told the customer the tests were added. applyChanges now reports `writeFailed`, beam-only so the IDE flow keeps its historical behaviour, and downloadDiffArtifact treats it exactly like backupFailed: apply-failed, no checkpoint save, retried. backupBeforeBeamOverwrite also pushed the path into backedUpFiles BEFORE the write was attempted, so a failed write still told the customer their file had been updated. It returns the path now, and each caller records it only once its own write has succeeded. findLoadedBeamCard told the customer "unit tests were generated but could not be added — please report this" on every poll of any multi-repo batch with no usable scope, whether or not a single unit test had been generated. Most such batches never ask for tests, so that is a false alarm and a support ticket. The message is now written only when some card has a SUCCEEDED Merge Tests step that is not yet applied; the diagnostic log line stays unconditional. Two comments were wrong and are fixed: the give-up constant said "for the rest of the session" when the decision is persisted and also stops later runs, and the JSDoc said backed-up files are "named in a log line" when they go to the worklog. Tests: 8 added, 606 pass. Four mutations each fail exactly their own test: never giving up on a diff that never arrives, ignoring a failed write, announcing the backup before the write, and warning about an unidentifiable repo unconditionally.
…utcome Two review findings, both verified against the code first. Two more in the same report were already fixed in 46bc754, which the review predates. downloadDiffArtifact returns the extracted folder with outcome 'apply-failed' when a file was kept or a write failed, so a non-empty path no longer means the diff landed. The step HITL caller still decided with `diffArtifactPath === ''`, which would have read a half-applied diff as applied and left DiffApplyFailed false, so the IDE's local-build loop would go on against an incomplete tree. It is unreachable today, because that caller does not opt into backups, but the return type now lets success and failure carry the same kind of path. It reads the outcome. A beam diff entry that climbs out of the solution with '..' (or arrives absolute) would be written outside it, and backed up outside it too, because backupConflictedFile derives the backup path from the same relative path. A Merge Tests diff is a test project and the .sln, so it has no business outside: the beam apply now refuses the whole diff before writing anything. #2969 adds containment to the whole apply path, for every caller; this is only the beam slice, so the gap is not open while that one is in review, and it sits in this PR's own guard block rather than in the four loops #2969 rewrites. Tests: 5 added, 611 pass. Two mutations each fail exactly their own test: dropping the containment check, and deciding the HITL result by path again.
…t it does Two review findings, both verified against the code first. The third in the same report is the end-to-end run and the #2969 merge order, already recorded in the description. The root and containment guards I added last commit refuse a diff on its own contents, so the answer cannot change on a retry — but they returned plain success:false, which downloadDiffArtifact turned into a thrown error. Every poll therefore re-requested the presigned URL and re-downloaded the archive three times, five polls deep, against a service the description itself says throttles us: fifteen pointless downloads for a verdict settled at the first one. Worse, the give-up then told the customer "some files may already have been updated" and pointed at conflict-backups, when nothing had been written at all. applyChanges now marks a refusal `rejected`, downloadDiffArtifact returns a `rejected` outcome without retrying and tells the customer the truth with the real reason, and the beam loop gives up on the first one without spending either bound. `backupBeforeOverwrite` had grown to control four things: the backups, the wrong-root refusal, the containment refusal, and write-failure reporting. Only the first matched the name, so the next caller that wanted backups alone would have got whole-diff refusals it never asked for. Renamed `beamApply`, which is what it selects, with the four behaviours listed where it is declared and a note to split it into an options object when a second caller wants some and not others — today that would be four parameters no live call site sets. Tests: 2 added, 613 pass. Two mutations each fail exactly their own test: letting a rejection fall into the retry loop, and letting it spend the poll bound.
…cooldown Three review findings, all verified against the code first. The fourth in the same report is the end-to-end run and the machine-readable step label, already recorded in the description. The wrong-root refusal was marked 'rejected', which means settled — and it is not. It reads the CUSTOMER'S working tree, and a Merge Tests diff usually updates the .sln alone, so a .sln they have momentarily renamed, moved or deleted looks exactly like a diff computed against another root. One such moment permanently retired the step and every later test run on the repo, recoverable only by deleting beam-apply-state.json by hand. It is an ordinary apply failure now, and goes through the 5-poll bound. Only the containment refusal stays 'rejected', because that one really is settled by the diff's own paths. The outcome doc says so explicitly. A failed write while adding the test project's files still let the filesUpdated pass overwrite the .sln — with the agent's version, which names the project that just failed to land. Visual Studio would reload a solution pointing at a missing .csproj and `dotnet build` would fail outright, and if the lock held for 5 polls it stayed broken for good. My own test asserted that behaviour, which is how it survived. On a beam apply, a failed add now skips the filesUpdated and movedFilesMap passes entirely: the retry lands the whole diff, and until then the customer's solution still builds. The non-beam apply still finishes the diff, and a test pins that. The beam apply ignored the throttle cooldown the rest of the beam code already honours. During a cooldown every poll still cost a ListArtifacts and up to three presigned-URL attempts for the same pending step, feeding the throttle the cooldown exists to relieve — and each of those polls spent the arrival bound, so a long enough window permanently retired a step whose artifact was fine. It now holds off and touches no state. Tests: 6 added, 617 pass. Three mutations each fail exactly their own test: calling a root mismatch settled, letting the .sln land after a failed add, and ignoring the cooldown.
… spending the bound Five review findings, all verified against the code first. The sixth is the end-to-end run, already recorded in the description. listArtifactsForStep swallowed every error, throttling included, and returned null — which the beam apply reads as "the artifact is not up yet" and counts against the permanent 30-poll arrival bound. So a sustained throttle retired a step whose artifact was perfectly fine, blocked every later test run on the card, and kept listing while it did, feeding the throttle. It now notes the throttle, which the loop's existing cooldown check honours on the next poll. (The review also flagged createArtifactDownloadUrl; that one already calls noteBeamThrottled.) Card selection failed OPEN on one card. With a single beam card the scope was not consulted at all, even when the IDE sent one naming none of that card's steps — which is the IDE saying the open solution belongs to another repo. That card's test project would be written into it whenever any updated path happened to exist there, and a shared .sln name is enough. One card with no scope stays the permissive case; one card with a scope that disagrees is now unresolved, the same as the multi-card branch on the same mismatch. The root check ran BEFORE the containment check, so it joined unvalidated entries like '../x' onto the solution root and probed outside it — and a diff whose paths all escape came back as the retryable wrong-root error rather than the settled refusal it is. Containment runs first now. A wrong-root refusal ended in the apply-bound give-up message, which says "Some files may already have been updated" and points at conflict-backups — but a refusal writes nothing, so that sent the customer hunting for damage that does not exist. applyChanges reports `wroteNothing`, downloadDiffArtifact turns it into a new `apply-refused` outcome that counts against the apply bound like any failure, and the give-up wording follows what actually happened. Last, the tests and comments contradicted the shipped contract: a fixture paired rejected:true with a wrong-root error, and a comment called a wrong root settled. Both now describe what the code does, which matters because the next maintainer reading them as the spec would "fix" the root refusal back into a permanent give-up over a momentarily renamed .sln. Tests: 5 added, 622 pass. Three mutations each fail exactly their own test: a silent throttled listing, a single card ignoring the scope, and one give-up wording for both kinds of failure.
5697edb to
5717ec5
Compare
…s mean applied Four review findings, three of them code. The fourth is the draft conversion, done separately on the PR itself. A wrong-root refusal shared the 5-attempt apply bound, and hitting that bound retires the step permanently and card-wide. But the refusal is read off the CUSTOMER'S tree, so a .sln renamed or moved during a branch switch looks exactly like a diff computed against another root — and five polls is about two minutes, which a branch switch easily outlasts. It now has its own counter with the generous 30-poll bound, and a refusal that clears up applies normally with nothing retired on the way. I did not take the rest of that suggestion. The review says the card-wide stop is unjustified for a refusal because nothing was written. The stop is not about this step's writes: the NEXT run's .sln names THIS run's test project, which never landed, so applying it would leave Visual Studio pointing at a .csproj that is not on disk. The stop stays, with that reason stated where it is enforced, and a test pins it. applyChanges could return success:true together with backupFailed or writeFailed, which both mean "did not land" — so a caller checking only success would record a half-landed diff as applied and retire the step. success now means the diff landed. Gated on beamApply: the IDE flow has always logged a per-file error and carried on, and its tests pin that. createArtifactDownloadUrl still built its own throttle predicate from the same name list and regex that isThrottleError now holds, so the two would drift and the cooldown they both feed would engage for one call path and not the other. One source of truth. Last, the .sln hold-back also triggers on a failed REMOVAL, but the log blamed an added file — an on-call engineer would hunt for a file that does not exist. The log names both, and a test covers the removal path, which had none. Tests: 5 added, 626 pass. Three mutations each fail exactly their own test: putting refusals back on the apply bound, success always true, and the log blaming only adds.
Important
Draft on purpose, so this cannot merge out of order. Two blockers, both tracked below:
applyChangesloops andbackupConflictedFile. After it lands, rebase this PR and deleteescapesRootand the beam-only containment block in favour of its general rule..slnis not at the repo root, and a two-repo batch. The observed scope payload and diff paths go in this description once they are known.Background
In Beam to IDE, a customer moves a repo from an AWS Transform web transformation into Visual Studio, and the language server shows the job's plan there. Once the local build check passes, the customer can ask in chat for unit tests. The transform agent then generates them and adds a "Merge Tests" step whose diff adds the test project to the solution (transform agent changes, already merged).
Problem
On a beam job the tests are generated but never reach the customer's solution, and nothing says so. The chat narrates success, both plan steps go green, no warning appears, and
DiffApplyFailedis never set. Only the file system disagrees.Two things cause it, and both are structural:
getTransformationPlanreturns early forisBeam, on purpose: the beamed source is already the transformed output, so the prior job's completed-step diffs are baked in and their checkpoint folders are not in the bundle. Applying them logs "Checkpoint folder not available for step" and setsDiffApplyFailedon every poll, stalling the LBV loop.findCompletedSteps'depth === 2would never match.Everything the agent side needs already happens — it uploads the tagged diff and marks the step SUCCEEDED regardless of beam. Only the LSP's selection is wrong.
Solution
Reuse the mechanism the normal IDE flow already uses, rather than reimplement it.
In the beam branch of
getTransformationPlan: find the loaded repo's beam card, take the SUCCEEDED "Merge Tests" steps under it in plan order, and hand each to the existingdownloadCompletedStepArtifact. That already downloads, retries, applies and records the step incheckpoints-applied.jsonexactly as it does for an IDE-started job, so already-applied steps are skipped and this is safe on every poll.Picking the solution
One beam card in the plan is unambiguous and needs no scope at all — the common case, since "beam
<repo>" beams one at a time.With several cards (a multi-repo batch) the IDE's scope says which repo is loaded, and it accepts either the card's own StepId or any step beneath it, since either identifies exactly one card. The feature therefore does not depend on which of the two the toolkit sends, a contract that is still unverified end to end.
If the scope cannot identify exactly one card, nothing is applied and an error is logged. The wrong repo's tests landing in this solution is worse than no tests.
With one card and no scope there is nothing to disambiguate, so the card is taken. With one card and a scope that names none of its steps, nothing is applied: that is the IDE saying the open solution belongs to a different repo, and a shared
.slnname would be enough for the root check to pass and the wrong repo's project to land. It fails closed, the same as the multi-card branch on the same mismatch.The customer is told about it only when some card has a SUCCEEDED Merge Tests step that is not yet applied. Most multi-repo batches never ask for unit tests, and telling those customers that tests "could not be added — please report this" is a false alarm that costs a support ticket. The log line is unconditional either way.
Only steps under a beam card apply. A web transform's own unit test run sits under the repo's transform card and its tests are already in the beamed zip, so it never applies again even when the scope covers that card.
Local edits are not preserved — they are backed up
A beam job never passed through
startTransform, so it has nosource-files.json.writeSourceFilesManifesthas exactly one caller, on the IDE start path, gated on interactive mode. Without that manifest,getModifiedFilesSinceCheckpointreturns[]andapplyChangeshas nothing to protect against, silently.Rather than rebuild that protection, the beam apply copies every file it would overwrite into
conflict-backupsfirst. These rules make that safe to rely on:successfromapplyChangesmeans the diff landed. A kept file or a failed write makes it false, so a caller that checkssuccessalone can never record a half-landed diff as applied. Gated onbeamApply: the IDE flow has always logged a per-file error and carried on, and its tests pin that;applyChangescatches every per-file copy, unlink and mkdir error, logs it and returns success — right for a large IDE transform diff, wrong for a Merge Tests diff that is one test project plus the.sln, where one file locked by Visual Studio or a virus scanner leaves the solution naming a.csprojthat is not on disk. It now reportswriteFailed, beam-only, and the step is retried rather than recorded as applied;.slnlanding at all. The updates and moves are skipped, so the solution is never left naming the test project that just failed to write. The retry lands the whole diff; until then the customer's solution still builds. The non-beam apply still finishes the diff, and a test pins that;preserveExistingflag that only the beam apply passes, so other callers keep one backup per path, replaced in place, exactly as before;Known consequence, accepted deliberately. The agent's
.slnis the server's whole solution, so overwriting it restores a project the customer removed locally. It is backed up and reported.This is the behaviour customers already have. The released IDE unit test generation applies the same way — whole
after/files, the.slnreplaced outright — so the beam path is not introducing a new risk, it is matching one customers are already told about. The only difference is the edit watermark: the IDE flow hassource-files.jsonand protects edits made after a previous apply, and a beam job has no manifest, so it protects none. Backing up every overwritten file is what covers that gap, and on a first apply the two paths are identical anyway, because the IDE flow has no watermark yet either.The alternative is a structural
.slnmerge, which is a much larger change for a rarer case; the proper fix is a three-way merge against the beamed original, below.Failures
A step that has not applied stops the loop rather than letting a later one land first:
applyChangescopies wholeafter/files, so an earlier diff landing after a later one would overwrite the later one's.slnand drop its test project from the solution.listArtifactsForStepswallowed every error, throttling included, and returned null — which reads as "not up yet" and spends the arrival bound, retiring a healthy step while continuing to feed the throttle. It notes the throttle now, so the cooldown below actually engages. (createArtifactDownloadUrlalready did.)The beam apply also honours the throttle cooldown the rest of the beam code already does (
isBeamThrottleCooldownActive, 90s). While it is active the loop holds off and touches no state: otherwise every poll would cost a ListArtifacts and up to three presigned-URL attempts for the same pending step — feeding the very throttle the cooldown exists to relieve — and would spend the arrival bound below, permanently retiring a step whose artifact is fine.A step still unapplied after 5 attempts is given up on, so it cannot re-list artifacts and retry its download on every poll against a service that already throttles us.
Only an attempt that got the diff onto disk counts against that bound.
downloadDiffArtifactreports one of five outcomes, anddownloadCompletedStepArtifactaddsno-artifact:appliedapply-failedapply-refusedrejectedtransfer-failedno-artifactCounted in attempts, not elapsed time, so a laptop sleep cannot spend either budget.
A diff that never arrives — no artifact in the listing, a throttled presigned URL, a dropped download, a bad archive — gets its own, far looser bound: 30 polls, in a separate
waitscounter. Most of those causes clear up on their own, which is why it must not share the apply bound. But it still has to be bounded, because a never-uploaded artifact and a corrupt archive never clear up: without it, that step re-lists and re-downloads on every poll for the rest of the job against a throttling service, every later run stays blocked behind it, and the customer is told nothing — the silent failure this PR exists to remove. Tripping it writes one worklog line saying the tests could not be downloaded.Giving up stops the whole card, not just that step. The agent's
.slnis the server's whole solution, so a later run's.slnalready names the given-up run's test project — and that project's files never landed. Applying it would leave Visual Studio with a solution pointing at a.csprojthat is not on disk, whichdotnet buildfails on outright. The give-up message says the tests could not be finished, that some files may already have been updated —applyChangeswrites file by file, so a diff that failed part way through has already landed in part — whereconflict-backupsis, and that later test runs in this job will not be added either. Later polls stay quiet rather than repeating it.Checking the diff belongs to this tree
Every path in the agent's
metadata.jsonis relative, and on a beam the solution root comes from the toolkit rather than from an upload by this LSP, so nothing else says the two agree.filesUpdatedandfilesRemovedname files that must already be there, so if none of them exists under the solution root, the diff was computed against a different tree. The beam apply refuses before writing anything, rather than scattering a test project and a.slnover unrelated directories. Beam-only, and skipped when the diff only adds files, where there is nothing to check.It also refuses a diff whose entries reach outside the solution —
..or an absolute path. A Merge Tests diff is a test project and the.sln, so it has no business outside, and such an entry would be written outside the solution and backed up outside it too, sincebackupConflictedFilederives the backup path from the same relative path. #2969 adds containment to the whole apply path for every caller; this is only the beam slice, so the gap is not open while that one is in review.The containment refusal is settled — it comes from the diff's own paths, so a retry cannot change the answer. It returns
rejected, which skips the three download retries and the five-poll bound entirely, and the customer is told at once, with the real reason, that nothing in the solution was changed.The root refusal deliberately is not settled, because it reads the customer's working tree. A Merge Tests diff usually updates only the
.sln, so a.slnthey have momentarily renamed, moved or deleted looks exactly like a diff computed against another root. It gets its own counter and the generous 30-poll bound, not the 5-attempt apply bound: five polls is about two minutes, which a branch switch easily outlasts, and the give-up is permanent.The card-wide stop still applies when a refusal never clears. Nothing was written by this step — but the next run's
.slnnames this run's test project, which never landed, so applying it would leave Visual Studio pointing at a.csprojthat is not on disk.These are guards, not a substitute for the end-to-end run below.
That count and the give-up decision live in
beam-apply-state.json, besidecheckpoints-applied.json. They have to be on disk for two reasons.getTransformInfocallsclearJobCacheon every poll of a COMPLETED job, immediately before fetching the plan, so an in-memory count would never reach its bound. And a given-up step is not incheckpoints-applied.json, so without a record a later session would retry it and could apply it after a step that comes later in the plan — overwriting that step's.slnand dropping its test project, the exact hazard the in-loop stop exists to prevent.Every outcome the customer could care about reaches the worklog: tests added, files overwritten, a step given up on, and a scope that cannot identify which beamed repo this solution is. Deliberately not
_currentDiffContext, which becomesDiffApplyFailedand would stall the local-build loop.A finished "Generate Unit Tests" run with no step named "Merge Tests" under it logs a
[BEAM]line and reaches the worklog, so a step rename shows up instead of silently matching nothing. Matched on the name alone, and checked for every run on the card: requiring SUCCEEDED as well would call a FAILED merge step — what a run that generated no tests leaves behind — a rename, and only looking when the card yields no steps at all would miss a rename landing mid-job after an earlier run matched, which is the case it exists for. Recorded in the apply state so the customer is told once, not on every poll.Overlaps with #2969
#2969 ("keep transform checkpoint changes inside the solution folder") touches the same four
applyChangesloops this PR hooks the backup into, and changesbackupConflictedFile. It should merge first and this PR rebase on top of it — its containment rule is the general case, and this PR's beam guard is a subset that can then be dropped. Whoever merges second hand-mergesapplyChanges.One premise was worth re-checking against it: this PR relies on a beam job having no
source-files.json. #2969 makeswriteSourceFilesManifestunconditional rather than interactive-mode-only, but it is still called fromstartTransformalone, which a beam job never reaches. The premise holds.Scope of change
The shared apply path gains one optional boolean,
beamApply(default false), andbackupConflictedFilegainspreserveExisting(default false). Both default to the historical behaviour, so every non-beam path behaves exactly as before, and tests pin the non-beam replace-in-place backup, its unchanged handling of paths outside the solution, and the absence of write-failure reporting.beamApplyselects four things, which is why it is named for the caller rather than for one effect: the backups, the wrong-root refusal, the containment refusal, and write-failure reporting. They are listed where it is declared. It should become an options object as soon as a second caller wants some of them and not others; today that would be four parameters no live call site sets.Considered and not done
source-files.jsonourselves would let the beam reuse the IDE's protection, but that mechanism needs a full enumeration of the source tree and compares every file against a singlelastAppliedTimestamp. It is weaker than backing everything up, it puts a synchronous filesystem walk on the thread that serves completions, andupdateWorkspacealso reads that file, so writing one would change what gets uploaded back..slnmerge that transplants only the agent's newProjectstanzas. Correct, and the right end state, but it is most of the complexity for the rarest case.downloadBeamArtifactleavesbeam-artifact.zipnext to the extracted repo and never deletes it, so the original bytes are already on disk. This is the intended follow-up; it needs the end-to-end run to confirm where the zip sits relative to the solution root and how its entry paths map.Release
Only the language server changes. The Visual Studio toolkit already shows the "Generate Unit Tests" step under the beam card and keeps polling after the build check, and it downloads the newest compatible 1.x language server at runtime, so this reaches customers with a language server release alone (no toolkit release).
Testing
New unit tests in
atxTransformHandler.test.ts:downloadDiffArtifactreportstransfer-failedwhen the download URL never comes and when the download throws, andapply-failedwhenapplyChangesfails on every retry; the step HITL caller treats a half-applied diff as failed even though it returns a path, and an applied one as applied;.., in a move, or as an absolute path, writes nothing at all — not even the entries that were inside — while the non-beam apply still decides that for itself;rejected, is not downloaded again, is given up on at the first poll without spending either bound, and the customer is told the real reason and that nothing changed; a wrong-root refusal isapply-refusedinstead — retryable, counted against the apply bound, and its give-up says nothing in the solution was changed rather than pointing at backups that do not exist;isThrottleErroris the only definition of a throttle, shared withcreateArtifactDownloadUrl;applyChangesreportssuccess: falsewhenever the diff did not land; a failed removal holds the.slnback and the log says so;.slnand the moves untouched, with the log line saying why, while the non-beam apply still completes the diff;.slnnames the missing test project; a give-up is remembered by a fresh handler, so neither it nor anything after it is ever applied in a later session;applyChangeson a temp solution: the customer's file is copied toconflict-backupsbefore being overwritten, before being deleted, and before a move overwrites it, with the backup-failure path covered for each; the backed-up paths are returned for the caller to report; identical bytes are not backed up; a second beam run does not replace the first backup, while a non-beam conflict still replaces in place; a failed backup leaves the step out ofcheckpoints-applied.json; the apply state round-trips to disk; and the non-beam apply takes no backups and behaves exactly as before.All netTransform tests pass (626); tsc and prettier clean, no ESLint errors. Twenty-four mutations each fail exactly their own test: putting refusals back on the apply bound,
successalways true, a log that blames only adds, a silent throttled listing, a single card ignoring a contradicting scope, one give-up wording for both kinds of failure, calling a root mismatch settled, letting the.slnland after a failed add, ignoring the throttle cooldown, letting a refused diff fall into the retry loop, letting it spend the poll bound, dropping the containment check, deciding the HITL result by path rather than outcome, turning the plan-order stop into acontinue, letting a given-up step fall through to the next run, counting a missing artifact or a failed download against the apply bound, never giving up on a diff that never arrives, dropping the solution-root check, putting the status back into the rename check, ignoring a failed write, announcing a backup before the write, warning about an unidentifiable repo unconditionally, and letting the checkpoint save run on a failed backup.End to end in Visual Studio (beam a repo, ask for unit tests after the build check, see them added to the solution) is still to run. Two cases are worth running deliberately: a repo whose
.slnis not at the repo root, which is what the solution-root check above is for, and a two-repo batch, which is what exercises the scope. The observed scope payload and diff paths will go in this description once they are known.License
By submitting this pull request, I confirm that my contribution is made under the terms of the Apache 2.0 license.