Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions src/Polyphony/Commands/PlanCommands.SeedChildren.cs
Original file line number Diff line number Diff line change
Expand Up @@ -582,6 +582,17 @@ public async Task<int> SeedChildren(
{
await twig.PatchFieldsAsync(workItem,
new Dictionary<string, string> { ["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;
}
Expand Down
91 changes: 90 additions & 1 deletion tests/Polyphony.Tests/Commands/PlanCommandsSeedChildrenTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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" },
Expand All @@ -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<!-- polyphony:plan-child-id=task-1 -->"}]""";
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()
{
Expand Down
Loading