diff --git a/src/Polyphony/Commands/PlanCommands.SeedChildren.cs b/src/Polyphony/Commands/PlanCommands.SeedChildren.cs index 08ac3c6f..ba4da02c 100644 --- a/src/Polyphony/Commands/PlanCommands.SeedChildren.cs +++ b/src/Polyphony/Commands/PlanCommands.SeedChildren.cs @@ -582,6 +582,17 @@ public async Task SeedChildren( { await twig.PatchFieldsAsync(workItem, new Dictionary { ["System.Tags"] = tags.Format() }, ct).ConfigureAwait(false); + + // Flush the staged parent-tag patch to ADO before + // returning. `twig patch` only mutates the local cache + + // pending queue; without this push the planned/facets + // tag is invisible to any subsequent process (e.g. + // `state_detector` in `apex-driver.yaml` checking + // `polyphony:planned`) that reads cache directly without + // first calling sync. AB#3128: sister-bug to AB#3126 — + // same staged-but-never-pushed failure mode at the + // seeder's parent-tag stamping edge. + await twig.SyncAsync(ct).ConfigureAwait(false); } tagSet = true; } diff --git a/tests/Polyphony.Tests/Commands/PlanCommandsSeedChildrenTests.cs b/tests/Polyphony.Tests/Commands/PlanCommandsSeedChildrenTests.cs index 7a46a258..c32a6b91 100644 --- a/tests/Polyphony.Tests/Commands/PlanCommandsSeedChildrenTests.cs +++ b/tests/Polyphony.Tests/Commands/PlanCommandsSeedChildrenTests.cs @@ -35,7 +35,16 @@ private static void StubShowParent(FakeProcessRunner runner, int parentId, strin } private static void StubPatchOk(FakeProcessRunner runner) - => runner.WhenStartsWith("twig", new[] { "patch" }, new ProcessResult(0, "{}", "")); + { + runner.WhenStartsWith("twig", new[] { "patch" }, new ProcessResult(0, "{}", "")); + // The seeder's parent-tag patch is followed by `twig sync` to flush + // the staged change to ADO (AB#3128). Tests that stub the patch + // also need to stub the sync, otherwise FakeProcessRunner throws. + StubSync(runner); + } + + private static void StubSync(FakeProcessRunner runner) + => runner.WhenExact("twig", new[] { "sync", "--output", "json" }, new ProcessResult(0, "{}", "")); private static void StubCreateChild(FakeProcessRunner runner, int newId) => runner.WhenStartsWith("twig", new[] { "new" }, @@ -57,6 +66,86 @@ public async Task SeedChildren_EmptyChildrenNoFacets_RoutesError() result.PlannedTagSet.ShouldBeFalse(); } + [Fact] + public async Task SeedChildren_AfterParentTagPatch_FlushesViaSync() + { + // AB#3128 regression: `seed-children` must call `twig sync` + // immediately after `twig patch` so the staged parent-tag + // mutation (polyphony:planned, polyphony:facets=...) is durable + // in ADO before the verb returns. Otherwise the staged change is + // invisible to the next reader (e.g. `state_detector` in + // `apex-driver.yaml` checking `polyphony:planned`), which reads + // cache directly without syncing first. Sister-bug to AB#3126 + // (BranchCommands.NextImpl + close-scope). + var (cmd, runner) = CreateCommand(); + StubShowTreeNoChildren(runner, 100); + StubShowParent(runner, 100, tagsField: ""); + StubPatchOk(runner); + + var planFile = WriteTempPlanFile(100, "---\napex_facets: [implementable]\n---\n"); + try + { + var (exit, _) = await CaptureConsoleAsync( + () => cmd.SeedChildren(100, "[]", "polyphony:planned", planFile)); + exit.ShouldBe(ExitCodes.Success); + + // Find the index of the `twig patch` invocation; the post-patch + // flush must come after it. + var patchIdx = -1; + for (var i = 0; i < runner.Invocations.Count; i++) + { + var inv = runner.Invocations[i]; + if (inv.Executable == "twig" + && inv.Arguments.Count >= 1 + && inv.Arguments[0] == "patch") + { + patchIdx = i; + break; + } + } + patchIdx.ShouldBeGreaterThan(-1, "expected a twig patch invocation for the parent tag"); + + var postPatchSync = runner.Invocations + .Select((inv, idx) => (inv, idx)) + .FirstOrDefault(x => x.idx > patchIdx + && x.inv.Executable == "twig" + && x.inv.Arguments.Count >= 1 + && x.inv.Arguments[0] == "sync"); + + postPatchSync.inv.ShouldNotBeNull( + "seed-children must invoke `twig sync` after `twig patch` to flush the staged parent-tag mutation (AB#3128)"); + } + finally + { + File.Delete(planFile); + } + } + + [Fact] + public async Task SeedChildren_NoParentTagPatch_DoesNotFlush() + { + // Belt-and-suspenders: when no patch was needed (tag already + // present on parent, idempotent re-run), the post-patch flush is + // skipped — no need to push an empty change set. AB#3128. + var (cmd, runner) = CreateCommand(); + const string existing = """[{"id":555,"title":"Do thing","type":"Task","description":"x\n"}]"""; + StubShowTreeChildren(runner, 100, existing); + StubShowParent(runner, 100, tagsField: "polyphony:planned; other-tag"); + // No patch stub and no sync stub — neither must be called. + + var children = """[{"child_id":"task-1","title":"Do thing","type":"Task","description":"x"}]"""; + var (exit, _) = await CaptureConsoleAsync(() => cmd.SeedChildren(100, children)); + exit.ShouldBe(ExitCodes.Success); + + var patchCalled = runner.Invocations.Any(i => + i.Executable == "twig" && i.Arguments.Count >= 1 && i.Arguments[0] == "patch"); + patchCalled.ShouldBeFalse(); + + var syncCalled = runner.Invocations.Any(i => + i.Executable == "twig" && i.Arguments.Count >= 1 && i.Arguments[0] == "sync"); + syncCalled.ShouldBeFalse(); + } + [Fact] public async Task SeedChildren_EmptyChildrenWithApexFacets_StampsTags() {