diff --git a/CHANGELOG.md b/CHANGELOG.md
index f8f1b26..4a14904 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -120,6 +120,19 @@ is not part of this repository.
- Just before the process is ended, a force stop looks at it once more. If a service has started
inside it since the preview, or a running service outside it has started to depend on something
inside it, the process is not ended and the step says why.
+- Stop pressed before the first step of a restart, or Ctrl+C, no longer starts a service that was
+ not running. A run now starts again only what it stopped itself: after an interruption, after a
+ stop that was refused, and for a dependent somebody else stopped between the preview and the
+ run, the step says "not started, this run never stopped it" and `skippedBecause` in the JSON of
+ a run is `nothingToPutBack`. The same goes for a restart of a whole selection interrupted half
+ way. A service stopped by somebody else after the preview of its own restart is left stopped,
+ and the run says it did not end where the plan wanted it.
+- Restarting a service that is not running only starts it, from the window and with
+ `bws restart`, and the preview says so - one step and the warning "is not running, so
+ restarting it only starts it" (`restartOnlyStarts` in the JSON). Until now the preview showed a
+ stop and a start. A disabled service that is not running is no longer refused with a sentence
+ about stopping it - the plan warns that Windows will refuse to start it, the same as a plan to
+ start it.
## [0.3.0] - 2026-09-25
diff --git a/src/Bws.Cli/PlanText.Starting.cs b/src/Bws.Cli/PlanText.Starting.cs
new file mode 100644
index 0000000..ccaa85c
--- /dev/null
+++ b/src/Bws.Cli/PlanText.Starting.cs
@@ -0,0 +1,33 @@
+using Bws.Core.Planning;
+
+namespace Bws.Cli;
+
+///
+/// What a plan says about the start in it - a start the manager is known to refuse, and a restart that is
+/// only a start because the entry is not running.
+///
+/// Its own file since 2026-09-30, reached from the discard arm of the warning switch beside it
+/// (stability report W-5, package D). The window's twin of that switch stood one fork under the complexity
+/// ceiling, and the third warning about a start would have taken it there - so the two that were already
+/// in it moved here with the new one, and both switches grew thinner rather than one of them fatter. The
+/// terminal is cut the same way so the two keep one shape. What this switch does not know goes on to
+/// , which keeps the refusal every kind without a sentence meets.
+///
+internal static partial class PlanText
+{
+ private static string Starting(PlanWarning warning) => warning.Kind switch
+ {
+ // The line that makes the start possible, built the way the offer to stop a disabled entry builds
+ // its own - the window has a startup type action for this and a terminal has the command.
+ PlanWarningKind.DisabledCannotStart => Texts.Of(
+ "cli.plan.warning.disabledCannotStart", warning.ServiceName,
+ EquivalentCommand.For(new ServiceAction(
+ ActionKind.SetStartType, warning.ServiceName, To: StartSetting.Manual))),
+
+ PlanWarningKind.PausedCannotStart => Texts.Of("cli.plan.warning.pausedCannotStart", warning.ServiceName),
+
+ PlanWarningKind.RestartOnlyStarts => Texts.Of("cli.plan.warning.restartOnlyStarts", warning.ServiceName),
+
+ _ => Aftermath(warning)
+ };
+}
diff --git a/src/Bws.Cli/PlanText.Warnings.cs b/src/Bws.Cli/PlanText.Warnings.cs
index 3f3cb58..5a95265 100644
--- a/src/Bws.Cli/PlanText.Warnings.cs
+++ b/src/Bws.Cli/PlanText.Warnings.cs
@@ -77,21 +77,13 @@ internal static partial class PlanText
PlanWarningKind.StartsAtNextBoot => Texts.Of("cli.plan.warning.startsAtNextBoot", warning.ServiceName),
- // The line that makes the start possible, built the way the offer above builds its own - the
- // window has a startup type action for this and a terminal has the command.
- PlanWarningKind.DisabledCannotStart => Texts.Of(
- "cli.plan.warning.disabledCannotStart", warning.ServiceName,
- EquivalentCommand.For(new ServiceAction(
- ActionKind.SetStartType, warning.ServiceName, To: StartSetting.Manual))),
-
- PlanWarningKind.PausedCannotStart => Texts.Of("cli.plan.warning.pausedCannotStart", warning.ServiceName),
-
// NAMED ARMS AND A REFUSAL, SINCE 2026-09-06, AND THE WILDCARD THAT WAS HERE IS WHY. Every
// kind but one used to fall through to "is already in that state, so nothing would change" -
// so a warning added without a sentence would not have been silent, which is survivable, but
// would have said something confident and wrong about a machine, which is not. The window's
// own switch had the same shape and was changed the same day. Since 2026-09-30 the refusal
- // stands at the end of the next switch along, which names what an ending sets off.
- _ => Aftermath(warning)
+ // stands at the end of a chain of switches along - the start in the plan first, then what an
+ // ending sets off.
+ _ => Starting(warning)
};
}
diff --git a/src/Bws.Cli/Resources/cli.en.json b/src/Bws.Cli/Resources/cli.en.json
index 6e97561..fba5db2 100644
--- a/src/Bws.Cli/Resources/cli.en.json
+++ b/src/Bws.Cli/Resources/cli.en.json
@@ -89,6 +89,7 @@
"cli.plan.warning.startsAtNextBoot": "{0} is stopped, and a start type does not start it. It starts at the next restart of the machine.",
"cli.plan.warning.disabledCannotStart": "{0} is disabled, and Windows refuses to start a disabled entry. To make it startable first: {1}",
"cli.plan.warning.pausedCannotStart": "{0} is paused, and a start does not resume a paused service - Windows will refuse it. To resume it instead: sc.exe continue {0}",
+ "cli.plan.warning.restartOnlyStarts": "{0} is not running, so restarting it only starts it.",
"cli.plan.warning.recoveryRestarts.one": "Once the process behind {0} is ended, Windows starts {2} again by itself - its recovery actions say so. The stop may not last. See them with sc.exe qfailure {2}",
"cli.plan.warning.recoveryRestarts.many": "Once the process behind {0} is ended, Windows starts {1} entries again by itself - their recovery actions say so: {2}. The stop may not last. See them with sc.exe qfailure",
"cli.plan.warning.recoveryRunsProgram.one": "Once the process behind {0} is ended, Windows runs the program named in the recovery actions of {2}. See it with sc.exe qfailure {2}",
@@ -115,6 +116,7 @@
"cli.run.outcome.earlierStepFailed": "not tried, an earlier step did not work",
"cli.run.outcome.cancelled": "not tried, the run was interrupted",
"cli.run.outcome.processStays": "not needed, the process it lives in is not being ended",
+ "cli.run.outcome.nothingToPutBack": "not started, this run never stopped it",
"cli.run.took.milliseconds": "{0} ms",
"cli.run.took.seconds": "{0} s",
diff --git a/src/Bws.Core/Planning/NetEffect.cs b/src/Bws.Core/Planning/NetEffect.cs
index f01df68..a9a9ab1 100644
--- a/src/Bws.Core/Planning/NetEffect.cs
+++ b/src/Bws.Core/Planning/NetEffect.cs
@@ -123,6 +123,26 @@ public static IReadOnlyList Of(IEnumerable results)
.Select(one => one.Step)];
}
+ ///
+ /// Whether these results took the entry down - asked by a step that puts the entry back, before it is
+ /// tried.
+ ///
+ /// HERE RATHER THAN IN THE RUNNER BECAUSE IT IS THE WAY BACK'S OWN QUESTION, and two answers to it
+ /// would be two things that have to agree - the argument this file was made for. On the owner's
+ /// decision of 2026-09-30 (stability report W-5) a step putting something back gives back only what the
+ /// run took: a stop that arrived or timed out, or an ending that took the entry with its process. Until
+ /// then every such step ran, and an entry that was stopped all along was started by a restart nobody
+ /// let begin.
+ ///
+ /// One case counts here and not in the way back: an ending after which Windows started the entry
+ /// again at once (). The process was ended, so the entry WAS taken
+ /// down - the step putting it back reads it, finds it running and says so. For the way back the same
+ /// entry ended where it began, which is why does not count it.
+ ///
+ internal static bool TookDown(IEnumerable results, string serviceName) =>
+ Tally(results, endingCounts: true).Moves.TryGetValue(serviceName, out var moved)
+ && moved.Last is StepOperation.Stop or StepOperation.Terminate;
+
///
/// Where each entry was first and last moved, and what each startup setting was before and after -
/// the two tallies turns into lines.
@@ -131,10 +151,14 @@ public static IReadOnlyList Of(IEnumerable results)
/// (stability report W-10) and Of went past the length the shape guard calls close to its ceiling.
/// The seam is the one the method already had: counting what happened, then saying what undoes it.
///
+ ///
+ /// Count an ending that Windows answered by starting the entry again at once -
+ /// asks with it, without.
+ ///
private static (
Dictionary Moves,
Dictionary Settings)
- Tally(IEnumerable results)
+ Tally(IEnumerable results, bool endingCounts = false)
{
var moves = new Dictionary(
StringComparer.OrdinalIgnoreCase);
@@ -166,7 +190,9 @@ private static (
foundStopped.Add(result.Step.ServiceName);
}
- if (result.Outcome != StepOutcome.Succeeded && result.Outcome != StepOutcome.TimedOut)
+ if (result.Outcome != StepOutcome.Succeeded
+ && result.Outcome != StepOutcome.TimedOut
+ && !(endingCounts && result.StartedAgain))
{
continue;
}
diff --git a/src/Bws.Core/Planning/PlanBuilder.cs b/src/Bws.Core/Planning/PlanBuilder.cs
index 75f8b3c..8dd90ed 100644
--- a/src/Bws.Core/Planning/PlanBuilder.cs
+++ b/src/Bws.Core/Planning/PlanBuilder.cs
@@ -52,6 +52,9 @@ public OperationPlan Build(ServiceAction action)
return refused;
}
+ // What is worked out below, which is not always what was asked for - AsPlanned says when. The
+ // plan still carries the ask itself, so the command it hands back is the one somebody typed.
+ var asked = AsPlanned(action, target);
var warnings = new List();
var steps = new List();
@@ -60,15 +63,15 @@ public OperationPlan Build(ServiceAction action)
// down - setting a start type does not move the service at all, so the question does not
// arise. Both end up with an empty list and they get there for different reasons. A setting
// carrying a stop is the exception, and it asks exactly what a plain stop asks.
- var blocking = action.Kind is ActionKind.Start
- || (action.Kind == ActionKind.SetStartType && !StopsAlong(action, target))
+ var blocking = asked.Kind is ActionKind.Start
+ || (asked.Kind == ActionKind.SetStartType && !StopsAlong(asked, target))
? []
: StoppingOrder(target, warnings);
// Asking to stop one service is not asking to stop seven. Without the word, the
// ones in the way are named and left alone, and the plan says plainly that the
// manager will refuse the stop while they run.
- var cascade = action.IncludeDependents ? blocking : [];
+ var cascade = asked.IncludeDependents ? blocking : [];
// Found by looking at a real plan rather than by reasoning: stopping BFE on this
// machine drags in WdNisDrv and wtd, both kernel drivers. Refusing a driver as the
@@ -103,12 +106,12 @@ public OperationPlan Build(ServiceAction action)
ending = decided;
}
- if (StuckDown(action.Kind, target, [.. cascade, .. ending?.Sharing ?? []]) is { Count: > 0 } cannotComeBack)
+ if (StuckDown(asked.Kind, target, [.. cascade, .. ending?.Sharing ?? []]) is { Count: > 0 } cannotComeBack)
{
return Refuse(action, PlanProblemKind.CannotComeBack, cannotComeBack);
}
- if (!action.IncludeDependents && blocking.Count > 0)
+ if (!asked.IncludeDependents && blocking.Count > 0)
{
warnings.Add(new PlanWarning(
PlanWarningKind.DependentsInTheWay,
@@ -116,9 +119,15 @@ public OperationPlan Build(ServiceAction action)
[.. blocking.Select(entry => entry.ServiceName)]));
}
- AddSteps(steps, action, target, cascade, ending);
+ AddSteps(steps, asked, target, cascade, ending);
- AddWarnings(warnings, target, action, cascade, ending);
+ AddWarnings(warnings, target, asked, cascade, ending);
+
+ if (asked != action)
+ {
+ // First, because it says what the whole plan is - every other sentence is about a step in it.
+ warnings.Insert(0, new PlanWarning(PlanWarningKind.RestartOnlyStarts, target.ServiceName));
+ }
return new OperationPlan
{
@@ -258,6 +267,10 @@ private static void AddSteps(
// machine on 2026-08-01 by pressing Ctrl+C during a restart, which left the
// service stopped - the plan had taken it down and then classified putting
// it back as forward progress to be abandoned.
+ //
+ // An entry read as stopped no longer reaches this arm since 2026-09-30 - it is
+ // planned as a start (AsPlanned) - and the runner gives back only what the run
+ // took down, so a restore after an interrupted or refused stop starts nothing.
AddStops(steps, cascade, target);
steps.Add(PlanSteps.Made(target, StepOperation.Start, StepReason.Restore));
@@ -315,6 +328,31 @@ kind is ActionKind.Restart or ActionKind.ForceRestart
.Select(entry => entry.ServiceName)]
: [];
+ ///
+ /// The ask as it is worked out: a restart of an entry that is not running becomes a start, and every
+ /// other ask stays what it was.
+ ///
+ /// The owner's decision of 2026-09-30 (stability report W-5), and it is the answer
+ /// Restart-Service gives - Microsoft's page for the cmdlet says a service already stopped is
+ /// started. Until then the plan was a stop the runner would find already done and a start that put the
+ /// entry back, and putting back is carried out even after an interruption - so Stop pressed before the
+ /// first step started a service that had been stopped all along. Planned as a start, it is a step
+ /// forward like any other, and Stop pressed before it starts nothing.
+ ///
+ /// No dependants come down. They stand in the way of stopping an entry, and this one is not
+ /// going to be stopped. The rules of a start apply whole: a disabled entry gets the warning a start of
+ /// it gets, where the restart used to refuse with a sentence about stopping it - false of an entry that
+ /// is not running.
+ ///
+ /// Only Stopped as read for the preview. An entry on its way down is still stopped by the plan,
+ /// the step waiting for it, and one whose state could not be read is read again by the runner before
+ /// anything is asked.
+ ///
+ private static ServiceAction AsPlanned(ServiceAction action, ScmEntry target) =>
+ action.Kind == ActionKind.Restart && target.Status == EntryStatus.Stopped
+ ? action with { Kind = ActionKind.Start }
+ : action;
+
///
/// Whether a startup setting carries a stop of its own entry - somebody took the offer, and the
/// entry is not already stopped. An entry whose state could not be read gets the step, because
diff --git a/src/Bws.Core/Planning/PlanRun.cs b/src/Bws.Core/Planning/PlanRun.cs
index d722d9e..b54916e 100644
--- a/src/Bws.Core/Planning/PlanRun.cs
+++ b/src/Bws.Core/Planning/PlanRun.cs
@@ -25,7 +25,7 @@ public enum StepOutcome
///
/// Why a step was never attempted.
///
-/// Four quite different stories, and folding them into one word would be the empty-value
+/// Five quite different stories, and folding them into one word would be the empty-value
/// mistake part 3 of 06-STRUKTURA-I-KONWENCJE is about: "skipped" alone cannot tell
/// somebody whether the machine is where they wanted it or half-way to somewhere else.
///
@@ -51,7 +51,21 @@ public enum SkipReason
/// the reason, not a failure. The neighbours are asked AFTER the entry itself since that day
/// (stability report W-2), so that a polite stop which works leaves them running.
///
- ProcessStays
+ ProcessStays,
+
+ ///
+ /// A step putting an entry back, not tried because this run never took that entry down - its stop
+ /// was interrupted, held back, refused, or found it already stopped.
+ ///
+ /// A fifth story, added on the owner's decision of 2026-09-30 (stability report W-5). Until
+ /// that day every step putting something back ran whatever had happened before it, on the belief
+ /// that an entry never taken down would be found already in place. That held for an entry left
+ /// running and failed for one that was stopped all along: pressing Stop before the first step of a
+ /// restart started it, and so did an interrupted bulk restart and a dependant somebody else had
+ /// stopped in the meantime. "Already there" would be false here - the entry may well be stopped -
+ /// and "interrupted" would not say why nothing was started when the run was not interrupted.
+ ///
+ NothingToPutBack
}
///
diff --git a/src/Bws.Core/Planning/PlanRunner.cs b/src/Bws.Core/Planning/PlanRunner.cs
index 30722e7..78d675a 100644
--- a/src/Bws.Core/Planning/PlanRunner.cs
+++ b/src/Bws.Core/Planning/PlanRunner.cs
@@ -86,12 +86,8 @@ public PlanRun Run(
cancelled |= cancellation.IsCancellationRequested;
abandoned |= abandonment.IsCancellationRequested;
- var because = Held(step.Reason, abandoned, cancelled, forwardFailed, cascadeFailed);
-
- if (because is null && step.Reason == StepReason.SharesTheProcess && Stays(plan))
- {
- because = SkipReason.ProcessStays;
- }
+ var because = Held(step.Reason, abandoned, cancelled, forwardFailed, cascadeFailed)
+ ?? Unneeded(step, plan, results);
if (because is { } skipped)
{
@@ -140,9 +136,10 @@ public PlanRun Run(
///
/// Putting things back is not part of the forward path and does not stop when the forward path
/// does. Those steps exist to give back what earlier steps took, and abandoning them would
- /// leave the machine trimmed by a plan that failed - the one outcome nobody asked for. Anything
- /// that was never taken down is found already in place and reported as such, so this costs
- /// nothing when it is not needed.
+ /// leave the machine trimmed by a plan that failed - the one outcome nobody asked for. What
+ /// was never taken down is not given back at all, since 2026-09-30 -
+ /// says why. This comment used to say such an entry would be found already in place, which was
+ /// true of an entry left running and false of one stopped all along (stability report W-5).
///
/// THE STEPS STANDING BEHIND THE ENTRY'S OWN STOP NEED THE OPPOSITE TREATMENT. The ending
/// of a process and, since 2026-09-29, the neighbours asked on the way to it exist for the case
@@ -173,6 +170,27 @@ public PlanRun Run(
: null;
}
+ ///
+ /// Why a step nothing held back is still not worth trying - or nothing when it is.
+ ///
+ /// Two answers, one question: the plan wanted this step only on the way to something that is not
+ /// going to happen. A neighbour is asked to stop only so its process can be ended, and
+ /// says when it will not be. A step putting an entry back exists only to give
+ /// back what the run took, and says when it took nothing.
+ ///
+ /// The second arrived on the owner's decision of 2026-09-30 (stability report W-5). Until then
+ /// every step putting something back was tried, so Stop pressed before the first step of a restart
+ /// started a service that had been stopped all along - and an interrupted bulk restart and a
+ /// dependant somebody else had stopped in the meantime did the same. Asked from what the run itself
+ /// recorded, so it costs no reading of the machine.
+ ///
+ private SkipReason? Unneeded(PlanStep step, OperationPlan plan, List results) => step.Reason switch
+ {
+ StepReason.SharesTheProcess when Stays(plan) => SkipReason.ProcessStays,
+ StepReason.Restore when !NetEffect.TookDown(results, step.ServiceName) => SkipReason.NothingToPutBack,
+ _ => null
+ };
+
///
/// Whether the process a plan ends is going to stay, asked just before a neighbour would be told
/// to stop on the way to ending it.
diff --git a/src/Bws.Core/Planning/PlanWarnings.cs b/src/Bws.Core/Planning/PlanWarnings.cs
index 7b6215c..1f84b03 100644
--- a/src/Bws.Core/Planning/PlanWarnings.cs
+++ b/src/Bws.Core/Planning/PlanWarnings.cs
@@ -197,7 +197,19 @@ public enum PlanWarningKind
/// four and Schedule carries a fifth. Said rather than guessed at, and never left out: the
/// owner's decision of 2026-09-30.
///
- RecoveryUnnamed
+ RecoveryUnnamed,
+
+ ///
+ /// A restart of an entry read as Stopped, planned as a start - there is nothing to stop, so all it
+ /// does is start it.
+ ///
+ /// The owner's decision of 2026-09-30 (stability report W-5), the answer Restart-Service
+ /// gives as well. Said because the plan no longer shows the stop somebody expects of a restart, and a
+ /// plan shorter than expected with no word about it reads as a plan that forgot something. Measured
+ /// before the change: bws restart AxInstSV --dry-run on a stopped entry of this machine showed a
+ /// stop and a start "put back" and said nothing about the entry not running.
+ ///
+ RestartOnlyStarts
}
///
diff --git a/src/Bws.Core/Planning/RefusedStartWarnings.cs b/src/Bws.Core/Planning/RefusedStartWarnings.cs
index 6fb3a63..9533694 100644
--- a/src/Bws.Core/Planning/RefusedStartWarnings.cs
+++ b/src/Bws.Core/Planning/RefusedStartWarnings.cs
@@ -12,9 +12,10 @@ namespace Bws.Core.Planning;
/// warning method stands near its ceilings, and the subject is its own - everything there is about
/// what a plan does, and this is about a start the machine is known to turn down.
///
-/// Only a plain start. A restart of a disabled entry is refused outright before any warning
-/// is worked out (PlanProblemKind.CannotComeBack), and a restart of a paused one stops it first,
-/// which a paused service accepts.
+/// Only a start - which since 2026-09-30 includes a restart of an entry read as stopped, planned as
+/// a start (PlanBuilder.AsPlanned). A restart of a running disabled entry is refused outright before
+/// any warning is worked out (PlanProblemKind.CannotComeBack), and a restart of a paused one stops
+/// it first, which a paused service accepts.
///
internal static class RefusedStartWarnings
{
diff --git a/src/Bws.Gui/Resources/gui.en.json b/src/Bws.Gui/Resources/gui.en.json
index 703e0c4..8f78441 100644
--- a/src/Bws.Gui/Resources/gui.en.json
+++ b/src/Bws.Gui/Resources/gui.en.json
@@ -276,6 +276,7 @@
"gui.plan.warning.startsAtNextBoot": "{0} is stopped, and a startup type does not start it. It starts at the next restart of the machine.",
"gui.plan.warning.disabledCannotStart": "{0} is disabled, and Windows refuses to start a disabled entry. Change its startup type first.",
"gui.plan.warning.pausedCannotStart": "{0} is paused, and a start does not resume a paused service - Windows will refuse it.",
+ "gui.plan.warning.restartOnlyStarts": "{0} is not running, so restarting it only starts it.",
"gui.plan.warning.recoveryRestarts.one": "Once the process behind {0} is ended, Windows starts {2} again by itself - its recovery actions say so. The stop may not last.",
"gui.plan.warning.recoveryRestarts.many": "Once the process behind {0} is ended, Windows starts {1} entries again by itself - their recovery actions say so: {2}. The stop may not last.",
"gui.plan.warning.recoveryRunsProgram.one": "Once the process behind {0} is ended, Windows runs the program named in the recovery actions of {2}.",
diff --git a/src/Bws.Gui/ViewModels/PlanWords.Starting.cs b/src/Bws.Gui/ViewModels/PlanWords.Starting.cs
new file mode 100644
index 0000000..583499f
--- /dev/null
+++ b/src/Bws.Gui/ViewModels/PlanWords.Starting.cs
@@ -0,0 +1,28 @@
+using Bws.Core.Planning;
+
+namespace Bws.Gui.ViewModels;
+
+///
+/// What a plan says about the start in it - a start the manager is known to refuse, and a restart that is
+/// only a start because the entry is not running.
+///
+/// Its own file since 2026-09-30, reached from the discard arm of the warning switch beside it
+/// (stability report W-5, package D). That switch stood one fork under the complexity ceiling, and the
+/// third warning about a start would have taken it there - so the two that were already in it moved here
+/// with the new one, and the switch grew thinner rather than fatter. The terminal's PlanText is cut the
+/// same way, so the two keep one shape. What this switch does not know goes on to
+/// , which keeps the refusal every kind without a sentence meets.
+///
+internal static partial class PlanWords
+{
+ private static string Starting(PlanWarning warning) => warning.Kind switch
+ {
+ PlanWarningKind.DisabledCannotStart => Texts.Of("gui.plan.warning.disabledCannotStart", warning.ServiceName),
+
+ PlanWarningKind.PausedCannotStart => Texts.Of("gui.plan.warning.pausedCannotStart", warning.ServiceName),
+
+ PlanWarningKind.RestartOnlyStarts => Texts.Of("gui.plan.warning.restartOnlyStarts", warning.ServiceName),
+
+ _ => Aftermath(warning)
+ };
+}
diff --git a/src/Bws.Gui/ViewModels/PlanWords.Warnings.cs b/src/Bws.Gui/ViewModels/PlanWords.Warnings.cs
index 2cdd23a..ad69792 100644
--- a/src/Bws.Gui/ViewModels/PlanWords.Warnings.cs
+++ b/src/Bws.Gui/ViewModels/PlanWords.Warnings.cs
@@ -97,16 +97,13 @@ internal static partial class PlanWords
PlanWarningKind.StartsAtNextBoot => Texts.Of("gui.plan.warning.startsAtNextBoot", warning.ServiceName),
- PlanWarningKind.DisabledCannotStart => Texts.Of("gui.plan.warning.disabledCannotStart", warning.ServiceName),
-
- PlanWarningKind.PausedCannotStart => Texts.Of("gui.plan.warning.pausedCannotStart", warning.ServiceName),
-
// NAMED ARMS AND A REFUSAL, SINCE 2026-09-06, AND THE WILDCARD THAT WAS HERE IS WHY. Every
// kind but one used to fall through to "is already in that state, so nothing would change" -
// a warning added without a sentence would have said something confident and wrong about a
// machine rather than nothing at all. The terminal's own switch had the same shape and was
- // changed the same day. Since 2026-09-30 the refusal stands at the end of the next switch
- // along, which names what an ending sets off - this one is one fork under the ceiling.
- _ => Aftermath(warning)
+ // changed the same day. Since 2026-09-30 the refusal stands at the end of a chain of switches
+ // along - the start in the plan first, then what an ending sets off - and this one gave its
+ // two arms about a start to the first of them rather than growing to the ceiling.
+ _ => Starting(warning)
};
}
diff --git a/tests/Bws.Cli.Tests/PutBackOutputTests.cs b/tests/Bws.Cli.Tests/PutBackOutputTests.cs
new file mode 100644
index 0000000..cbf0961
--- /dev/null
+++ b/tests/Bws.Cli.Tests/PutBackOutputTests.cs
@@ -0,0 +1,59 @@
+using System.Text.Json;
+using Bws.Core;
+using Bws.Core.Planning;
+
+namespace Bws.Cli.Tests;
+
+///
+/// What the terminal and the machine readable output say about a step that had nothing to put back and a
+/// restart that only starts - stability report W-5, the owner's decision of 2026-09-30.
+///
+/// The sentence is found by a key built from the value's name, so a reason added without one would
+/// print its own key rather than fail - which is why the first test asks every reason, not only the new one.
+///
+public sealed class PutBackOutputTests
+{
+ [Fact]
+ public void Every_reason_a_step_was_skipped_has_a_sentence_of_its_own()
+ {
+ var said = Enum.GetValues().Select(reason => PlanText.Describe(Skipped(reason))).ToList();
+
+ Assert.All(said, sentence => Assert.DoesNotContain("cli.run.outcome", sentence, StringComparison.Ordinal));
+ Assert.Equal(said.Count, said.Distinct(StringComparer.Ordinal).Count());
+ }
+
+ [Fact]
+ public void The_document_names_the_reason_and_the_warning()
+ {
+ var run = new PlanRun
+ {
+ Plan = new OperationPlan
+ {
+ Action = new ServiceAction(ActionKind.Restart, "AxInstSV"),
+ Steps = [Skipped(SkipReason.NothingToPutBack).Step],
+ Warnings = [new PlanWarning(PlanWarningKind.RestartOnlyStarts, "AxInstSV", [])],
+ Problems = []
+ },
+ Results = [Skipped(SkipReason.NothingToPutBack)],
+ Cancelled = false,
+ Ceiling = TimeSpan.FromMinutes(1)
+ };
+
+ var document = JsonDocument.Parse(PlanJson.Render(run)).RootElement;
+
+ Assert.Equal("nothingToPutBack", document.GetProperty("results")[0].GetProperty("skippedBecause").GetString());
+ Assert.Equal("restartOnlyStarts", document.GetProperty("warnings")[0].GetProperty("kind").GetString());
+ }
+
+ private static StepResult Skipped(SkipReason reason) => new()
+ {
+ Step = new PlanStep("AxInstSV", "ActiveX Installer", StepOperation.Start, StepReason.Restore),
+ Outcome = StepOutcome.Skipped,
+ SkippedBecause = reason,
+ Status = EntryStatus.Unknown,
+ ProcessId = Reading.NotRead(),
+ ErrorCode = 0,
+ Error = null,
+ Milliseconds = 0
+ };
+}
diff --git a/tests/Bws.Core.Tests/PlanRunnerTests.cs b/tests/Bws.Core.Tests/PlanRunnerTests.cs
index 9427114..9136667 100644
--- a/tests/Bws.Core.Tests/PlanRunnerTests.cs
+++ b/tests/Bws.Core.Tests/PlanRunnerTests.cs
@@ -225,9 +225,10 @@ public void A_restart_that_fails_half_way_still_puts_back_what_it_took_down()
Assert.False(run.Completed);
Assert.Equal(StepOutcome.Failed, Outcome(run, "MRxSmb20", StepOperation.Stop));
- // Its own start gives it back, so it is tried rather than abandoned - and finds
- // nothing to do, because the stop that failed left it running.
- Assert.Equal(SkipReason.AlreadyThere, Result(run, "MRxSmb20", StepOperation.Start).SkippedBecause);
+ // Its own start is not tried, because the stop that failed never took it down. Until
+ // 2026-09-30 it was tried and found the entry running - the same machine, and a
+ // reason that only held because the entry happened to be running (stability report W-5).
+ Assert.Equal(SkipReason.NothingToPutBack, Result(run, "MRxSmb20", StepOperation.Start).SkippedBecause);
foreach (var name in (string[])["LanmanWorkstation", "Netlogon", "SessionEnv"])
{
@@ -273,8 +274,9 @@ public void A_restore_that_fails_does_not_stop_the_other_restores()
Assert.Equal(StepOutcome.Failed, Outcome(run, "LanmanWorkstation", StepOperation.Stop));
- // Nothing after it went down, so there is nothing to put back and the restores find
- // their work done. What matters is that they were reached at all.
+ // Nothing after it went down, so the restores of those have nothing to put back - and
+ // say so - while the two dependants that did go down come back. What matters is that
+ // no restore was held back by the failure itself.
Assert.All(
run.Results.Where(result => result.Step.Reason == StepReason.Restore),
result => Assert.NotEqual(SkipReason.EarlierStepFailed, result.SkippedBecause));
@@ -333,8 +335,10 @@ public void A_step_announces_its_place_in_the_plan_rather_than_its_place_in_the_
starting: (_, number) => announced.Add(number));
// Three and four are missing, because the forward path stopped at the refusal, and
- // the numbers that follow do not close the gap. That is the whole point.
- Assert.Equal([1, 2, 5, 6, 7, 8], announced);
+ // five to seven since 2026-09-30, because what they would put back was never taken
+ // down - Netlogon refused and the two behind it were not tried. The one number left
+ // after the gap does not close it. That is the whole point.
+ Assert.Equal([1, 2, 8], announced);
}
[Fact]
diff --git a/tests/Bws.Core.Tests/PutBackTests.cs b/tests/Bws.Core.Tests/PutBackTests.cs
new file mode 100644
index 0000000..44787e4
--- /dev/null
+++ b/tests/Bws.Core.Tests/PutBackTests.cs
@@ -0,0 +1,210 @@
+using Bws.Core.Planning;
+using Bws.Core.Tests.Fakes;
+
+namespace Bws.Core.Tests;
+
+///
+/// A run gives back only what it took down - the external stability report of 2026-09-29 (W-5) and the
+/// owner's decision on it of 2026-09-30.
+///
+/// Three shapes of one mistake, and each started a service the run had never touched. Stop pressed
+/// before the first step of a restart, a restart of a whole selection interrupted half way, and a
+/// dependant somebody else stopped between the preview and the run. Every step putting something back used
+/// to be tried whatever had happened before it.
+///
+/// The assertions are about what the manager was ASKED, because that is the half a reported outcome
+/// can hide - and each is red on the code before that day for exactly that reason.
+///
+public sealed class PutBackTests
+{
+ private static readonly TimeSpan Minute = TimeSpan.FromMinutes(1);
+
+ private const int Held = 4812;
+
+ [Fact]
+ public void Stop_pressed_before_a_restart_of_a_stopped_entry_starts_nothing()
+ {
+ using var interruption = new CancellationTokenSource();
+ interruption.Cancel();
+
+ var control = new FakeScmControl().At("AxInstSV", EntryStatus.Stopped);
+
+ var run = new PlanRunner(control, new FakeClock()).Run(
+ Planned(ActionKind.Restart, Entry("AxInstSV", EntryStatus.Stopped)), Minute, interruption.Token);
+
+ Assert.Equal(SkipReason.Cancelled, Assert.Single(run.Results).SkippedBecause);
+ Assert.Empty(control.Requested);
+ }
+
+ [Fact]
+ public void Stop_pressed_before_a_restart_gives_nothing_back()
+ {
+ using var interruption = new CancellationTokenSource();
+ interruption.Cancel();
+
+ // Running for the preview and stopped by somebody else since - the one shape in which the step
+ // putting it back used to do harm rather than find nothing to do.
+ var control = new FakeScmControl().At("Spooler", EntryStatus.Stopped);
+
+ var run = new PlanRunner(control, new FakeClock()).Run(
+ Planned(ActionKind.Restart, Entry("Spooler", EntryStatus.Running)), Minute, interruption.Token);
+
+ Assert.Equal(
+ [SkipReason.Cancelled, SkipReason.NothingToPutBack],
+ run.Results.Select(result => result.SkippedBecause));
+
+ Assert.Empty(control.Requested);
+ }
+
+ [Fact]
+ public void A_dependant_somebody_else_stopped_before_the_run_is_left_stopped()
+ {
+ var control = new FakeScmControl()
+ .At("Lanman", EntryStatus.Running)
+ .At("Dependant", EntryStatus.Stopped);
+
+ var run = Run(control, Planned(
+ ActionKind.Restart, Entry("Lanman", EntryStatus.Running), Entry("Dependant", EntryStatus.Running)));
+
+ Assert.Equal(SkipReason.AlreadyThere, Result(run, "Dependant", StepOperation.Stop).SkippedBecause);
+ Assert.Equal(SkipReason.NothingToPutBack, Result(run, "Dependant", StepOperation.Start).SkippedBecause);
+ Assert.Equal(["Lanman", "Lanman"], control.Requested);
+
+ // Not where the plan wanted it at the end, and said so rather than counted as done.
+ Assert.False(run.Completed);
+ }
+
+ [Fact]
+ public void A_neighbour_found_stopped_is_not_started_after_the_process_ends()
+ {
+ var control = new FakeScmControl()
+ .Reaching("Spooler", new ServiceProgress(EntryStatus.StopPending, 0, TimeSpan.Zero, Held))
+ .At("Housemate", EntryStatus.Stopped);
+
+ var run = Run(control, ForcedRestart(
+ Step("Spooler", StepOperation.Stop, StepReason.Requested),
+ Step("Housemate", StepOperation.Stop, StepReason.SharesTheProcess),
+ Ending("Housemate"),
+ Step("Spooler", StepOperation.Start, StepReason.Restore),
+ Step("Housemate", StepOperation.Start, StepReason.Restore)));
+
+ // Its own stop found it stopped, so it was not in the process the ending took down.
+ Assert.Equal(SkipReason.NothingToPutBack, Result(run, "Housemate", StepOperation.Start).SkippedBecause);
+ Assert.Equal(StepOutcome.Succeeded, Result(run, "Spooler", StepOperation.Start).Outcome);
+ Assert.DoesNotContain("Housemate", control.Requested);
+ }
+
+ [Fact]
+ public void An_entry_its_ending_took_down_is_given_back_although_its_own_stop_was_refused()
+ {
+ // The ending is what took it down, so that is what the step putting it back has to count.
+ var control = new FakeScmControl().RefusingRequests("Spooler", 1052);
+
+ var run = Run(control, ForcedRestart(
+ Step("Spooler", StepOperation.Stop, StepReason.Requested),
+ Ending(),
+ Step("Spooler", StepOperation.Start, StepReason.Restore)));
+
+ Assert.Equal(Held, Assert.Single(control.Ended));
+
+ // Asked twice - the stop, and the start that gives it back. The double refuses both, which is
+ // beside the point: what is tested is that the second was asked at all.
+ Assert.Equal(["Spooler", "Spooler"], control.Requested);
+ }
+
+ [Fact]
+ public void An_ending_Windows_answered_by_starting_the_entry_again_still_took_it_down()
+ {
+ // The process was ended and the manager started the entry again at once. The step putting it back
+ // reads it and finds it running - which is a different sentence from "this run never stopped it".
+ var control = new FakeScmControl()
+ .ComingBack("Spooler", new ServiceProgress(EntryStatus.Running, 0, TimeSpan.Zero, 5555))
+ .At("Housemate", EntryStatus.Running);
+
+ var run = Run(control, ForcedRestart(
+ Ending("Housemate") with { Reason = StepReason.Requested },
+ Step("Spooler", StepOperation.Start, StepReason.Restore),
+ Step("Housemate", StepOperation.Start, StepReason.Restore)));
+
+ Assert.True(run.Results[0].StartedAgain);
+ Assert.Equal(SkipReason.AlreadyThere, Result(run, "Spooler", StepOperation.Start).SkippedBecause);
+
+ // The neighbour died with the process and nothing brought it back, so it is given back.
+ Assert.Equal(StepOutcome.Succeeded, Result(run, "Housemate", StepOperation.Start).Outcome);
+ }
+
+ [Fact]
+ public void An_interrupted_restart_of_a_selection_starts_nothing_it_did_not_stop()
+ {
+ using var interruption = new CancellationTokenSource();
+
+ var catalog = new FakeScmCatalog([Entry("First", EntryStatus.Running), Entry("Second", EntryStatus.Running)]);
+
+ var bulk = new BulkPlanBuilder(catalog.ReadAll(), catalog)
+ .Build(new BulkAction(ActionKind.Restart, ["First", "Second"], false));
+
+ // The second was stopped by somebody else after the preview.
+ var control = new FakeScmControl()
+ .At("First", EntryStatus.Running)
+ .At("Second", EntryStatus.Stopped);
+
+ var run = new BulkRunner(new PlanRunner(control, new FakeClock())).Run(
+ bulk, Minute, interruption.Token, starting: (_, _) => interruption.Cancel());
+
+ Assert.True(run.Cancelled);
+ Assert.DoesNotContain("Second", control.Requested);
+
+ Assert.Equal(
+ SkipReason.NothingToPutBack,
+ run.Runs.SelectMany(one => one.Results)
+ .Single(result => result.Step is { ServiceName: "Second", Operation: StepOperation.Start })
+ .SkippedBecause);
+ }
+
+ // -- fixtures --------------------------------------------------------------------------
+
+ /// A plan from the builder, the first entry the target and every other one depending on it.
+ private static OperationPlan Planned(ActionKind kind, ScmEntry target, params ScmEntry[] dependants)
+ {
+ var catalog = new FakeScmCatalog([target, .. dependants]);
+
+ if (dependants.Length > 0)
+ {
+ catalog.DependedOnBy(target.ServiceName, [.. dependants.Select(entry => entry.ServiceName)]);
+ }
+
+ return new PlanBuilder(catalog.ReadAll(), catalog)
+ .Build(new ServiceAction(kind, target.ServiceName, IncludeDependents: dependants.Length > 0));
+ }
+
+ private static ScmEntry Entry(string serviceName, EntryStatus status) =>
+ Entries.Named(serviceName, serviceName) with
+ {
+ Status = status,
+ StartType = Reading.Present(Core.StartType.Manual),
+ DelayedAuto = Reading.Absent(),
+ ProcessId = status == EntryStatus.Stopped ? Reading.Absent() : Reading.Present(4444)
+ };
+
+ private static PlanStep Step(string name, StepOperation operation, StepReason reason) =>
+ new(name, name, operation, reason);
+
+ private static PlanStep Ending(params string[] housemates) =>
+ new("Spooler", "Spooler", StepOperation.Terminate, StepReason.Escalation, ProcessId: Held, TakesWithIt: housemates);
+
+ private static OperationPlan ForcedRestart(params PlanStep[] steps) => new()
+ {
+ Action = new ServiceAction(ActionKind.ForceRestart, "Spooler"),
+ Steps = steps,
+ Warnings = [],
+ Problems = []
+ };
+
+ private static PlanRun Run(FakeScmControl control, OperationPlan plan) =>
+ new PlanRunner(control, new FakeClock()).Run(plan, TimeSpan.FromSeconds(30));
+
+ private static StepResult Result(PlanRun run, string serviceName, StepOperation operation) =>
+ Assert.Single(
+ run.Results,
+ result => result.Step.ServiceName == serviceName && result.Step.Operation == operation);
+}
diff --git a/tests/Bws.Core.Tests/RestartOfStoppedTests.cs b/tests/Bws.Core.Tests/RestartOfStoppedTests.cs
new file mode 100644
index 0000000..a42f6f4
--- /dev/null
+++ b/tests/Bws.Core.Tests/RestartOfStoppedTests.cs
@@ -0,0 +1,93 @@
+using Bws.Core.Planning;
+using Bws.Core.Tests.Fakes;
+
+namespace Bws.Core.Tests;
+
+///
+/// A restart of an entry that is not running is planned as its start - the external stability report of
+/// 2026-09-29 (W-5) and the owner's decision on it of 2026-09-30, which is also what Restart-Service does.
+///
+/// Measured before the change on this machine: bws restart AxInstSV --dry-run on a stopped
+/// entry showed a stop and a start "put back", and the start was carried out even when Stop was pressed
+/// before the first step. And a restart of a stopped disabled entry was refused with a sentence about
+/// stopping it, which it is not.
+///
+public sealed class RestartOfStoppedTests
+{
+ [Fact]
+ public void A_restart_of_a_stopped_entry_is_one_start_asked_for()
+ {
+ var plan = Build(new ServiceAction(ActionKind.Restart, "AxInstSV"), Entry("AxInstSV", EntryStatus.Stopped));
+
+ var step = Assert.Single(plan.Steps);
+
+ Assert.Equal(StepOperation.Start, step.Operation);
+ Assert.Equal(StepReason.Requested, step.Reason);
+
+ // The ask is kept, so the command handed back is the one somebody typed.
+ Assert.Equal(ActionKind.Restart, plan.Action.Kind);
+ Assert.Equal(PlanWarningKind.RestartOnlyStarts, plan.Warnings[0].Kind);
+ }
+
+ [Fact]
+ public void A_restart_of_a_running_entry_is_still_a_stop_and_a_start_put_back()
+ {
+ var plan = Build(new ServiceAction(ActionKind.Restart, "Spooler"), Entry("Spooler", EntryStatus.Running));
+
+ Assert.Equal(
+ [(StepOperation.Stop, StepReason.Requested), (StepOperation.Start, StepReason.Restore)],
+ plan.Steps.Select(step => (step.Operation, step.Reason)));
+
+ Assert.DoesNotContain(plan.Warnings, warning => warning.Kind == PlanWarningKind.RestartOnlyStarts);
+ }
+
+ [Fact]
+ public void A_restart_of_a_stopped_disabled_entry_warns_the_way_its_start_does()
+ {
+ var disabled = Entry("AmdCrash", EntryStatus.Stopped) with
+ {
+ StartType = Reading.Present(Core.StartType.Disabled)
+ };
+
+ var plan = Build(new ServiceAction(ActionKind.Restart, "AmdCrash"), disabled);
+
+ // Refused until 2026-09-30 with "restarting it would stop it and could not start it again" - false
+ // of an entry that is not running. Now what a start of it gets, on the owner's decision of package C.
+ Assert.True(plan.IsRunnable);
+
+ Assert.Equal(
+ [PlanWarningKind.RestartOnlyStarts, PlanWarningKind.DisabledCannotStart],
+ plan.Warnings.Select(warning => warning.Kind));
+ }
+
+ [Fact]
+ public void A_restart_of_a_stopped_entry_takes_no_dependant_down()
+ {
+ // A dependant still running on a stopped entry is rare and real - the entry died under it. It is not
+ // in the way of anything, because nothing here is going to be stopped.
+ var catalog = new FakeScmCatalog([Entry("Lanman", EntryStatus.Stopped), Entry("Dependant", EntryStatus.Running)])
+ .DependedOnBy("Lanman", "Dependant");
+
+ var plan = new PlanBuilder(catalog.ReadAll(), catalog)
+ .Build(new ServiceAction(ActionKind.Restart, "Lanman", IncludeDependents: true));
+
+ Assert.Equal("Lanman", Assert.Single(plan.Steps).ServiceName);
+ Assert.DoesNotContain(plan.Warnings, warning => warning.Kind == PlanWarningKind.Cascade);
+ }
+
+ private static OperationPlan Build(ServiceAction action, ScmEntry entry)
+ {
+ var catalog = new FakeScmCatalog([entry]);
+
+ return new PlanBuilder(catalog.ReadAll(), catalog).Build(action);
+ }
+
+ private static ScmEntry Entry(string serviceName, EntryStatus status) =>
+ Entries.Named(serviceName, serviceName) with
+ {
+ Status = status,
+ StartType = Reading.Present(Core.StartType.Manual),
+ DelayedAuto = Reading.Absent(),
+ ProcessId = status == EntryStatus.Stopped ? Reading.Absent() : Reading.Present(4444)
+ };
+}