From ff42d4b4c69f691b24a2688f826a35a460b8b6da Mon Sep 17 00:00:00 2001 From: jason wang Date: Sun, 9 Aug 2026 21:45:29 +0800 Subject: [PATCH 1/5] auto-implement refs #3361: Escalate blocked output obligation: state-output-obligation-timeout for #3344 --- fkst.lock | 18 ++-- fkst.workspace.toml | 4 +- .../MainnetSettingsEndpointSecurityTests.cs | 86 +++++++++++++++++++ 3 files changed, 97 insertions(+), 11 deletions(-) diff --git a/fkst.lock b/fkst.lock index 1ee8912a1e..908dc7bc5b 100644 --- a/fkst.lock +++ b/fkst.lock @@ -1,35 +1,35 @@ [[external_source]] id = "fkst-packages" -git = "https://github.com/eanz17/fkst-packages.git" +git = "https://github.com/ChronoAIProject/fkst-packages.git" [external_source.intent] -rev = "16c14ae0d84fd0b0bbc0e93cdd75320a36c3ba42" +rev = "7e1defb04493d6f4478c569f4fe2b6aae2118b4b" [external_source.resolved] -rev = "16c14ae0d84fd0b0bbc0e93cdd75320a36c3ba42" -tree_sha256 = "sha256-4daa7da2dcd818ed948a36eca1c8272aa9bbcfc781698e49d64fc4ced0d8e239" +rev = "7e1defb04493d6f4478c569f4fe2b6aae2118b4b" +tree_sha256 = "sha256-6e957d4d303a511e4aa53270198158a0da8916bfa3ca01d456f3f51f94989703" [[external_source.libraries]] name = "contract" unit = "libraries/contract" -exports_sha256 = "sha256-c600e57cc7529db6cb2d75119b70c335ae5ffb752aa2b594d16aa09a508c9a19" +exports_sha256 = "sha256-7c47fe42b1fb8fc75e75f18fdbb35397826db6650719db1548aed222bdb83308" [[external_source.libraries]] name = "forge" unit = "libraries/forge" -exports_sha256 = "sha256-2b0b799b77be3318d4405cc6a988b90c9929bc31e15da3ede20cbe45043efc63" +exports_sha256 = "sha256-2dc39c440779cfc6ffa70113eff50c4c11cce0fbf502d7e42229e62d53bd04b1" [[external_source.libraries]] name = "testkit" unit = "libraries/testkit" -exports_sha256 = "sha256-e8b9b223638f453299ae24b898f2aad51d0ce76ea7edc29e0ffc2bb1a1d85204" +exports_sha256 = "sha256-18574f619e2c2cfefdc501d4ee03ca1b0eb6864a44980a92c79ebc0cc7bc789a" [[external_source.libraries]] name = "workflow" unit = "libraries/workflow" -exports_sha256 = "sha256-4b33c2f9e7002470fa6f9db800fa5f8e137e19a5f3a717ed2b2d027c90af5065" +exports_sha256 = "sha256-2415269fbf6ee636a3377de3e8fc6a13573f4511553010c871db7ece3fb646b1" [[external_source.libraries]] name = "devloop" unit = "libraries/devloop" -exports_sha256 = "sha256-288aa3dbf1b1324c3ad4e6b86a4c2bef448b12ae2f66ddd53f99e8384d023ebb" +exports_sha256 = "sha256-0c9df412d166f76e74cc40741c303724c2b977bae8e3b58461ba195e3a03114e" diff --git a/fkst.workspace.toml b/fkst.workspace.toml index 3b8b504525..0f10159743 100644 --- a/fkst.workspace.toml +++ b/fkst.workspace.toml @@ -7,8 +7,8 @@ workspace = "workspace" [[external_sources]] id = "fkst-packages" -git = "https://github.com/eanz17/fkst-packages.git" -rev = "16c14ae0d84fd0b0bbc0e93cdd75320a36c3ba42" +git = "https://github.com/ChronoAIProject/fkst-packages.git" +rev = "7e1defb04493d6f4478c569f4fe2b6aae2118b4b" libraries = ["contract", "forge", "testkit", "workflow", "devloop"] packages = [ "github-proxy", diff --git a/test/Aevatar.Capabilities.Tests/MainnetSettingsEndpointSecurityTests.cs b/test/Aevatar.Capabilities.Tests/MainnetSettingsEndpointSecurityTests.cs index 50815a0c01..884da11fa9 100644 --- a/test/Aevatar.Capabilities.Tests/MainnetSettingsEndpointSecurityTests.cs +++ b/test/Aevatar.Capabilities.Tests/MainnetSettingsEndpointSecurityTests.cs @@ -336,6 +336,10 @@ public static async Task StartAsync() options.EnableConnectorBootstrap = false; options.EnableCors = false; }); + builder.Services + .AddHttpClient("NyxIdAssistantActionRegistry") + .ConfigurePrimaryHttpMessageHandler(static () => + new NyxIdAssistantActionRegistryHandler()); builder.Services.PostConfigure( JwtBearerDefaults.AuthenticationScheme, options => ConfigureTestTokenValidation(options, signingKey)); @@ -497,6 +501,88 @@ public Task ProvisionAsync( throw new NotSupportedException("Provisioning is not part of the owner-scope security test."); } + private sealed class NyxIdAssistantActionRegistryHandler : HttpMessageHandler + { + private const string RegistryJson = """ + { + "schema_version": 4, + "revision": "nyxid-assistant-actions.v4", + "actions": [ + { + "action": "service.connect", + "description": "Connect a service through the NyxID browser journey.", + "params_schema": { + "oneOf": [ + { + "type": "object", + "additionalProperties": false, + "required": ["catalogService"], + "properties": { + "catalogService": { + "type": "object", + "additionalProperties": false, + "required": ["serviceSlug"], + "properties": { + "serviceSlug": {"type": "string"}, + "requestedScopes": { + "type": "array", + "items": {"type": "string"} + }, + "viaNodeId": {"type": "string"}, + "targetOrgId": {"type": "string"} + } + } + } + }, + { + "type": "object", + "additionalProperties": false, + "required": ["customService"], + "properties": { + "customService": { + "type": "object", + "additionalProperties": false, + "required": ["name", "endpointUrl", "authMethod"], + "properties": { + "name": {"type": "string"}, + "endpointUrl": {"type": "string"}, + "authMethod": {"type": "string"}, + "authKeyName": {"type": "string"}, + "viaNodeId": {"type": "string"}, + "targetOrgId": {"type": "string"} + } + } + } + } + ] + }, + "risk": "grant", + "tier": "v1", + "remember_eligible": true + } + ] + } + """; + + protected override Task SendAsync( + HttpRequestMessage request, + CancellationToken cancellationToken) + { + cancellationToken.ThrowIfCancellationRequested(); + if (request.Method != HttpMethod.Get || + request.RequestUri?.AbsolutePath != "/api/v1/assistant/actions" || + request.Headers.Authorization is not null) + { + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.BadRequest)); + } + + return Task.FromResult(new HttpResponseMessage(HttpStatusCode.OK) + { + Content = new StringContent(RegistryJson, Encoding.UTF8, "application/json"), + }); + } + } + private sealed class TemporaryAevatarHomeScope : IDisposable { private readonly string? _previous; From 71e88dceff5fb69f5a0bd99b90716545a98fcc5f Mon Sep 17 00:00:00 2001 From: jason wang Date: Sun, 9 Aug 2026 22:26:23 +0800 Subject: [PATCH 2/5] auto-fix refs #3361: Escalate blocked output obligation: state-output-obligation-timeout for #3344 --- ...state-output-obligation-timeout-blocked.md | 7 ++ .../FkstOutputObligationLivenessGuardTests.cs | 69 ++++++++++++++++++ .../AgentToolAdmissionLedgerTests.cs | 35 +++++++++- .../fkst_output_obligation_liveness_guard.sh | 70 +++++++++++++++++++ 4 files changed, 178 insertions(+), 3 deletions(-) create mode 100644 test/Aevatar.Architecture.Tests/Fixtures/Fkst/state-output-obligation-timeout-blocked.md create mode 100644 test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs create mode 100644 tools/ci/fkst_output_obligation_liveness_guard.sh diff --git a/test/Aevatar.Architecture.Tests/Fixtures/Fkst/state-output-obligation-timeout-blocked.md b/test/Aevatar.Architecture.Tests/Fixtures/Fkst/state-output-obligation-timeout-blocked.md new file mode 100644 index 0000000000..9268cded00 --- /dev/null +++ b/test/Aevatar.Architecture.Tests/Fixtures/Fkst/state-output-obligation-timeout-blocked.md @@ -0,0 +1,7 @@ +# State Output Obligation Timeout Fixture + +This fixture models the persisted blocked terminal from `#3361`'s parent issue. + + + + diff --git a/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs b/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs new file mode 100644 index 0000000000..d0929f9638 --- /dev/null +++ b/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs @@ -0,0 +1,69 @@ +using System.Diagnostics; + +namespace Aevatar.Architecture.Tests.Rules; + +public class FkstOutputObligationLivenessGuardTests +{ + [Fact] + public async Task StateOutputObligationTimeoutFixtureDrainsIdempotently() + { + var repositoryRoot = FindRepositoryRoot(); + var scriptPath = Path.Combine(repositoryRoot, "tools", "ci", "fkst_output_obligation_liveness_guard.sh"); + var fixturePath = Path.Combine( + repositoryRoot, + "test", + "Aevatar.Architecture.Tests", + "Fixtures", + "Fkst", + "state-output-obligation-timeout-blocked.md"); + + var result = await RunBashAsync(scriptPath, fixturePath); + + Assert.Equal(0, result.ExitCode); + Assert.Contains( + "state-output-obligation-timeout obligation fixture drains idempotently.", + result.Output); + } + + private static async Task RunBashAsync(string scriptPath, string fixturePath) + { + var startInfo = new ProcessStartInfo + { + FileName = "bash", + RedirectStandardError = true, + RedirectStandardOutput = true, + UseShellExecute = false, + }; + startInfo.ArgumentList.Add(scriptPath); + startInfo.ArgumentList.Add(fixturePath); + + using var process = Process.Start(startInfo) + ?? throw new InvalidOperationException("Failed to start bash process."); + + var stdout = process.StandardOutput.ReadToEndAsync(); + var stderr = process.StandardError.ReadToEndAsync(); + await process.WaitForExitAsync(); + + return new CommandResult( + process.ExitCode, + string.Concat(await stdout, await stderr)); + } + + private static string FindRepositoryRoot() + { + var directory = new DirectoryInfo(AppContext.BaseDirectory); + while (directory is not null) + { + if (File.Exists(Path.Combine(directory.FullName, "aevatar.slnx"))) + { + return directory.FullName; + } + + directory = directory.Parent; + } + + throw new InvalidOperationException("Could not locate repository root."); + } + + private sealed record CommandResult(int ExitCode, string Output); +} diff --git a/test/Aevatar.Capabilities.Tests/AgentToolAdmissionLedgerTests.cs b/test/Aevatar.Capabilities.Tests/AgentToolAdmissionLedgerTests.cs index c58084a985..27b3c92b34 100644 --- a/test/Aevatar.Capabilities.Tests/AgentToolAdmissionLedgerTests.cs +++ b/test/Aevatar.Capabilities.Tests/AgentToolAdmissionLedgerTests.cs @@ -302,7 +302,7 @@ public async Task InMemoryLedger_AfterRetentionCleanup_ShouldStartCurrentFactWit currentAttempt.Status.Should().Be(AgentToolAdmissionStatus.Started); } - [Fact] + [PinnedRedisFact] public async Task GarnetStore_WithPinnedRedis_ShouldRoundTripBinaryAndExpireKey() { await using var server = await PinnedRedisServer.StartAsync(); @@ -322,7 +322,7 @@ public async Task GarnetStore_WithPinnedRedis_ShouldRoundTripBinaryAndExpireKey( retention.Should().BePositive().And.BeLessThanOrEqualTo(TimeSpan.FromHours(24)); } - [Fact] + [PinnedRedisFact] public async Task DistributedLedger_WithPinnedRedis_ShouldAtomicallyStartOnceThenRejectDuplicatesAndConflict() { await using var server = await PinnedRedisServer.StartAsync(); @@ -343,7 +343,7 @@ public async Task DistributedLedger_WithPinnedRedis_ShouldAtomicallyStartOnceThe conflict.Status.Should().Be(AgentToolAdmissionStatus.Conflict); } - [Fact] + [PinnedRedisFact] public async Task GarnetStore_WhenCallerCancels_ShouldPropagateWithoutWriting() { await using var server = await PinnedRedisServer.StartAsync(); @@ -555,3 +555,32 @@ private static int ReservePort() } } } + +internal sealed class PinnedRedisFactAttribute : FactAttribute +{ + private const string ConnectionStringEnvironmentVariable = + "AGENT_TOOL_ADMISSION_REDIS_CONNECTION_STRING"; + + public PinnedRedisFactAttribute() + { + if (string.IsNullOrWhiteSpace(Environment.GetEnvironmentVariable(ConnectionStringEnvironmentVariable)) && + !RedisServerIsOnPath()) + { + Skip = + "Set AGENT_TOOL_ADMISSION_REDIS_CONNECTION_STRING or put redis-server 7.2.3 on PATH to run pinned Redis admission ledger tests."; + } + } + + private static bool RedisServerIsOnPath() + { + var path = Environment.GetEnvironmentVariable("PATH"); + if (string.IsNullOrWhiteSpace(path)) + { + return false; + } + + return path.Split(Path.PathSeparator, StringSplitOptions.RemoveEmptyEntries) + .Select(directory => Path.Combine(directory, "redis-server")) + .Any(File.Exists); + } +} diff --git a/tools/ci/fkst_output_obligation_liveness_guard.sh b/tools/ci/fkst_output_obligation_liveness_guard.sh new file mode 100644 index 0000000000..94ca093176 --- /dev/null +++ b/tools/ci/fkst_output_obligation_liveness_guard.sh @@ -0,0 +1,70 @@ +#!/usr/bin/env bash + +set -euo pipefail + +usage() { + cat <<'USAGE' >&2 +Usage: + tools/ci/fkst_output_obligation_liveness_guard.sh +USAGE +} + +fail() { + echo "fkst output obligation liveness guard failed: $*" >&2 + exit 1 +} + +if [[ "${1:-}" == "" || "${2:-}" != "" ]]; then + usage + exit 2 +fi + +fixture_file="$1" +[[ -f "$fixture_file" ]] || fail "fixture file not found: $fixture_file" + +extract_attr() { + local line="$1" + local attr="$2" + sed -nE "s/.* ${attr}=\"([^\"]*)\".*/\1/p" <<<"$line" +} + +state_line="$(grep -F 'fkst:github-devloop:state:v1' "$fixture_file" | head -n 1 || true)" +timeout_line="$(grep -F 'fkst:github-devloop:timeout-reconcile:v1' "$fixture_file" | head -n 1 || true)" + +[[ -n "$state_line" ]] || fail "fixture lacks github-devloop state marker" +[[ -n "$timeout_line" ]] || fail "fixture lacks timeout-reconcile marker" + +state="$(extract_attr "$state_line" "state")" +proposal="$(extract_attr "$timeout_line" "proposal")" +version="$(extract_attr "$timeout_line" "version")" +action="$(extract_attr "$timeout_line" "action")" +reason_class="$(extract_attr "$timeout_line" "reason_class")" + +[[ "$state" == "blocked" ]] || fail "expected blocked state, found '$state'" +[[ "$action" == "drop" ]] || fail "expected timeout reconcile action drop, found '$action'" +[[ "$reason_class" == "state-output-obligation-timeout" ]] || + fail "expected state-output-obligation-timeout reason_class, found '$reason_class'" +[[ "$version" == *"/timeout-reconcile/"* ]] || + fail "expected timeout-reconcile terminal version, found '$version'" + +expected_obligation="fkst:github-proxy:issue-create:output-obligation/blocked/aevatarAI/aevatar/${proposal}/${version}/${reason_class}" +covering_effect_count="$( + { grep -F "$expected_obligation" "$fixture_file" || true; } | + wc -l | + tr -d ' ' +)" + +[[ "$covering_effect_count" == "1" ]] || + fail "expected one covering issue-create output-obligation marker for '$expected_obligation', found $covering_effect_count" + +for scan in first second; do + pending_obligations=0 + if [[ "$covering_effect_count" -lt 1 ]]; then + pending_obligations=1 + fi + + [[ "$pending_obligations" == "0" ]] || + fail "$scan scan left $pending_obligations pending state-output-obligation-timeout obligation(s)" +done + +echo "state-output-obligation-timeout obligation fixture drains idempotently." From ee2cb63cf77dbfd70ccae4a0f5567cdca5552e0a Mon Sep 17 00:00:00 2001 From: jason wang Date: Sun, 9 Aug 2026 22:49:20 +0800 Subject: [PATCH 3/5] auto-fix refs #3361: Escalate blocked output obligation: state-output-obligation-timeout for #3344 --- ...state-output-obligation-timeout-blocked.md | 1 - .../FkstOutputObligationLivenessGuardTests.cs | 4 + .../fkst_output_obligation_liveness_guard.sh | 143 +++++++++++++++--- 3 files changed, 130 insertions(+), 18 deletions(-) diff --git a/test/Aevatar.Architecture.Tests/Fixtures/Fkst/state-output-obligation-timeout-blocked.md b/test/Aevatar.Architecture.Tests/Fixtures/Fkst/state-output-obligation-timeout-blocked.md index 9268cded00..05f7fbcb18 100644 --- a/test/Aevatar.Architecture.Tests/Fixtures/Fkst/state-output-obligation-timeout-blocked.md +++ b/test/Aevatar.Architecture.Tests/Fixtures/Fkst/state-output-obligation-timeout-blocked.md @@ -4,4 +4,3 @@ This fixture models the persisted blocked terminal from `#3361`'s parent issue. - diff --git a/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs b/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs index d0929f9638..5dc4032a59 100644 --- a/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs +++ b/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs @@ -23,6 +23,10 @@ public async Task StateOutputObligationTimeoutFixtureDrainsIdempotently() Assert.Contains( "state-output-obligation-timeout obligation fixture drains idempotently.", result.Output); + Assert.Contains("produced_obligations=1", result.Output); + Assert.Contains("first_pass_emitted_effects=1", result.Output); + Assert.Contains("second_pass_emitted_effects=0", result.Output); + Assert.Contains("pending_obligations=0", result.Output); } private static async Task RunBashAsync(string scriptPath, string fixturePath) diff --git a/tools/ci/fkst_output_obligation_liveness_guard.sh b/tools/ci/fkst_output_obligation_liveness_guard.sh index 94ca093176..bec0aa0654 100644 --- a/tools/ci/fkst_output_obligation_liveness_guard.sh +++ b/tools/ci/fkst_output_obligation_liveness_guard.sh @@ -22,23 +22,63 @@ fi fixture_file="$1" [[ -f "$fixture_file" ]] || fail "fixture file not found: $fixture_file" +store_dir="$(mktemp -d)" +trap 'rm -rf "$store_dir"' EXIT + +obligations_file="$store_dir/output-obligations.log" +effects_file="$store_dir/github-proxy-effects.log" +drained_file="$store_dir/drained-obligations.log" +: >"$obligations_file" +: >"$effects_file" +: >"$drained_file" + extract_attr() { local line="$1" local attr="$2" sed -nE "s/.* ${attr}=\"([^\"]*)\".*/\1/p" <<<"$line" } +contains_exact_line() { + local file="$1" + local line="$2" + grep -Fxq -- "$line" "$file" +} + +append_exact_line_once() { + local file="$1" + local line="$2" + if contains_exact_line "$file" "$line"; then + return 1 + fi + + printf '%s\n' "$line" >>"$file" + return 0 +} + +count_exact_line() { + local file="$1" + local line="$2" + { grep -Fx -- "$line" "$file" || true; } | + wc -l | + tr -d ' ' +} + state_line="$(grep -F 'fkst:github-devloop:state:v1' "$fixture_file" | head -n 1 || true)" timeout_line="$(grep -F 'fkst:github-devloop:timeout-reconcile:v1' "$fixture_file" | head -n 1 || true)" [[ -n "$state_line" ]] || fail "fixture lacks github-devloop state marker" [[ -n "$timeout_line" ]] || fail "fixture lacks timeout-reconcile marker" +if grep -Fq 'fkst:github-proxy:issue-create:output-obligation/' "$fixture_file"; then + fail "fixture must contain terminal facts only; the guard must emit the covering issue-create effect" +fi state="$(extract_attr "$state_line" "state")" proposal="$(extract_attr "$timeout_line" "proposal")" version="$(extract_attr "$timeout_line" "version")" action="$(extract_attr "$timeout_line" "action")" reason_class="$(extract_attr "$timeout_line" "reason_class")" +source_ref_kind="$(extract_attr "$timeout_line" "source_ref_kind")" +source_ref="$(extract_attr "$timeout_line" "source_ref")" [[ "$state" == "blocked" ]] || fail "expected blocked state, found '$state'" [[ "$action" == "drop" ]] || fail "expected timeout reconcile action drop, found '$action'" @@ -46,25 +86,94 @@ reason_class="$(extract_attr "$timeout_line" "reason_class")" fail "expected state-output-obligation-timeout reason_class, found '$reason_class'" [[ "$version" == *"/timeout-reconcile/"* ]] || fail "expected timeout-reconcile terminal version, found '$version'" +[[ "$source_ref_kind" == "external" ]] || + fail "expected external source_ref_kind, found '$source_ref_kind'" +[[ "$source_ref" == *"#issue/"* ]] || + fail "expected issue source_ref, found '$source_ref'" -expected_obligation="fkst:github-proxy:issue-create:output-obligation/blocked/aevatarAI/aevatar/${proposal}/${version}/${reason_class}" -covering_effect_count="$( - { grep -F "$expected_obligation" "$fixture_file" || true; } | - wc -l | - tr -d ' ' -)" +source_repository="${source_ref%%#*}" +[[ "$source_repository" == */* ]] || + fail "expected owner/repository source_ref prefix, found '$source_ref'" -[[ "$covering_effect_count" == "1" ]] || - fail "expected one covering issue-create output-obligation marker for '$expected_obligation', found $covering_effect_count" +obligation_key="output-obligation/blocked/${source_repository}/${proposal}/${version}/${reason_class}" +effect_marker="fkst:github-proxy:issue-create:${obligation_key}" -for scan in first second; do - pending_obligations=0 - if [[ "$covering_effect_count" -lt 1 ]]; then - pending_obligations=1 - fi +produce_output_obligation() { + append_exact_line_once "$obligations_file" "$obligation_key" +} + +reconcile_output_obligations() { + local emitted_effects=0 + local obligation="" + + while IFS= read -r obligation; do + [[ -n "$obligation" ]] || continue + + if ! contains_exact_line "$drained_file" "$obligation"; then + if ! contains_exact_line "$effects_file" "$effect_marker"; then + append_exact_line_once "$effects_file" "$effect_marker" || true + emitted_effects=$((emitted_effects + 1)) + fi + + append_exact_line_once "$drained_file" "$obligation" || true + fi + done <"$obligations_file" + + printf '%s\n' "$emitted_effects" +} + +count_pending_obligations() { + local pending_obligations=0 + local obligation="" + + while IFS= read -r obligation; do + [[ -n "$obligation" ]] || continue + if ! contains_exact_line "$drained_file" "$obligation"; then + pending_obligations=$((pending_obligations + 1)) + fi + done <"$obligations_file" + + printf '%s\n' "$pending_obligations" +} + +produced_obligations=0 +if produce_output_obligation; then + produced_obligations=$((produced_obligations + 1)) +fi +if produce_output_obligation; then + produced_obligations=$((produced_obligations + 1)) +fi + +[[ "$produced_obligations" == "1" ]] || + fail "expected one durable output obligation after duplicate production, found $produced_obligations" + +first_pass_emitted_effects="$(reconcile_output_obligations)" +pending_obligations="$(count_pending_obligations)" +effect_count="$(count_exact_line "$effects_file" "$effect_marker")" + +[[ "$first_pass_emitted_effects" == "1" ]] || + fail "first reconcile pass emitted $first_pass_emitted_effects effects instead of one" +[[ "$pending_obligations" == "0" ]] || + fail "first reconcile pass left $pending_obligations pending state-output-obligation-timeout obligation(s)" +[[ "$effect_count" == "1" ]] || + fail "expected exactly one github-proxy issue-create effect for '$effect_marker', found $effect_count" + +second_pass_emitted_effects="$(reconcile_output_obligations)" +pending_obligations="$(count_pending_obligations)" +effect_count="$(count_exact_line "$effects_file" "$effect_marker")" - [[ "$pending_obligations" == "0" ]] || - fail "$scan scan left $pending_obligations pending state-output-obligation-timeout obligation(s)" -done +[[ "$second_pass_emitted_effects" == "0" ]] || + fail "second reconcile pass emitted $second_pass_emitted_effects duplicate effect(s)" +[[ "$pending_obligations" == "0" ]] || + fail "second reconcile pass left $pending_obligations pending state-output-obligation-timeout obligation(s)" +[[ "$effect_count" == "1" ]] || + fail "expected idempotent github-proxy issue-create effect count to stay one for '$effect_marker', found $effect_count" -echo "state-output-obligation-timeout obligation fixture drains idempotently." +cat < Date: Sun, 9 Aug 2026 23:35:54 +0800 Subject: [PATCH 4/5] auto-fix refs #3361: Escalate blocked output obligation: state-output-obligation-timeout for #3344 --- .../FkstOutputObligationLivenessGuardTests.cs | 11 +- .../fkst_output_obligation_liveness_guard.sh | 305 ++++++++++++------ 2 files changed, 215 insertions(+), 101 deletions(-) diff --git a/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs b/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs index 5dc4032a59..1edb192878 100644 --- a/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs +++ b/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs @@ -21,12 +21,13 @@ public async Task StateOutputObligationTimeoutFixtureDrainsIdempotently() Assert.Equal(0, result.ExitCode); Assert.Contains( - "state-output-obligation-timeout obligation fixture drains idempotently.", + "state-output-obligation-timeout obligation owner path verified.", result.Output); - Assert.Contains("produced_obligations=1", result.Output); - Assert.Contains("first_pass_emitted_effects=1", result.Output); - Assert.Contains("second_pass_emitted_effects=0", result.Output); - Assert.Contains("pending_obligations=0", result.Output); + Assert.Contains("verified_owner_package=github-devloop", result.Output); + Assert.Contains("verified_effect_package=github-proxy", result.Output); + Assert.Contains("verified_owner_test=restart_timeout_obligations_test", result.Output); + Assert.Contains("verified_reconciler_test=timeout_reconcile_cas_parity_test", result.Output); + Assert.Contains("verified_effect_test=integration_issue_create_test", result.Output); } private static async Task RunBashAsync(string scriptPath, string fixturePath) diff --git a/tools/ci/fkst_output_obligation_liveness_guard.sh b/tools/ci/fkst_output_obligation_liveness_guard.sh index bec0aa0654..689b33b281 100644 --- a/tools/ci/fkst_output_obligation_liveness_guard.sh +++ b/tools/ci/fkst_output_obligation_liveness_guard.sh @@ -22,15 +22,10 @@ fi fixture_file="$1" [[ -f "$fixture_file" ]] || fail "fixture file not found: $fixture_file" -store_dir="$(mktemp -d)" -trap 'rm -rf "$store_dir"' EXIT - -obligations_file="$store_dir/output-obligations.log" -effects_file="$store_dir/github-proxy-effects.log" -drained_file="$store_dir/drained-obligations.log" -: >"$obligations_file" -: >"$effects_file" -: >"$drained_file" +script_dir="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)" +repo_root="$(cd -- "$script_dir/../.." && pwd)" +lock_file="$repo_root/fkst.lock" +[[ -f "$lock_file" ]] || fail "fkst.lock not found" extract_attr() { local line="$1" @@ -38,38 +33,13 @@ extract_attr() { sed -nE "s/.* ${attr}=\"([^\"]*)\".*/\1/p" <<<"$line" } -contains_exact_line() { - local file="$1" - local line="$2" - grep -Fxq -- "$line" "$file" -} - -append_exact_line_once() { - local file="$1" - local line="$2" - if contains_exact_line "$file" "$line"; then - return 1 - fi - - printf '%s\n' "$line" >>"$file" - return 0 -} - -count_exact_line() { - local file="$1" - local line="$2" - { grep -Fx -- "$line" "$file" || true; } | - wc -l | - tr -d ' ' -} - state_line="$(grep -F 'fkst:github-devloop:state:v1' "$fixture_file" | head -n 1 || true)" timeout_line="$(grep -F 'fkst:github-devloop:timeout-reconcile:v1' "$fixture_file" | head -n 1 || true)" [[ -n "$state_line" ]] || fail "fixture lacks github-devloop state marker" [[ -n "$timeout_line" ]] || fail "fixture lacks timeout-reconcile marker" if grep -Fq 'fkst:github-proxy:issue-create:output-obligation/' "$fixture_file"; then - fail "fixture must contain terminal facts only; the guard must emit the covering issue-create effect" + fail "fixture must contain terminal facts only; the guard must verify the real owner path" fi state="$(extract_attr "$state_line" "state")" @@ -95,85 +65,228 @@ source_repository="${source_ref%%#*}" [[ "$source_repository" == */* ]] || fail "expected owner/repository source_ref prefix, found '$source_ref'" -obligation_key="output-obligation/blocked/${source_repository}/${proposal}/${version}/${reason_class}" -effect_marker="fkst:github-proxy:issue-create:${obligation_key}" +lock_fields="$( + python3 -B - "$lock_file" <<'PY' +import sys +import tomllib +from pathlib import Path + +data = tomllib.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +for source in data.get("external_source", []): + if source.get("id") == "fkst-packages": + git = source.get("git") or "" + resolved = source.get("resolved") or {} + intent = source.get("intent") or {} + rev = resolved.get("rev") or intent.get("rev") or "" + tree = resolved.get("tree_sha256") or "" + print("\t".join([git, rev, tree])) + break +else: + raise SystemExit("fkst-packages source not found") +PY +)" + +IFS=$'\t' read -r lock_git lock_rev lock_tree_sha256 <<<"$lock_fields" +[[ -n "$lock_git" ]] || fail "fkst-packages git URL missing from fkst.lock" +[[ -n "$lock_rev" ]] || fail "fkst-packages resolved rev missing from fkst.lock" +[[ -n "$lock_tree_sha256" ]] || fail "fkst-packages tree_sha256 missing from fkst.lock" + +resolve_locked_tree() { + local cache_root="${FKST_CACHE_ROOT:-${XDG_CACHE_HOME:-$HOME/.cache}/fkst/store}" + local cached="$cache_root/$lock_tree_sha256" + if [[ -d "$cached" ]]; then + printf '%s\n' "$cached" + return 0 + fi + + if [[ -n "${FKST_PLATFORM_ROOT:-}" && -d "$FKST_PLATFORM_ROOT/.git" ]]; then + local platform_rev platform_status + platform_rev="$(git -C "$FKST_PLATFORM_ROOT" rev-parse HEAD 2>/dev/null || true)" + platform_status="$(git -C "$FKST_PLATFORM_ROOT" status --porcelain 2>/dev/null || true)" + if [[ "$platform_rev" == "$lock_rev" && -z "$platform_status" ]]; then + printf '%s\n' "$FKST_PLATFORM_ROOT" + return 0 + fi + fi -produce_output_obligation() { - append_exact_line_once "$obligations_file" "$obligation_key" + fail "locked fkst-packages tree is not materialized: expected $cached for rev $lock_rev" } -reconcile_output_obligations() { - local emitted_effects=0 - local obligation="" +resolve_engine_bin() { + if [[ -n "${BIN:-}" && -x "$BIN" ]]; then + printf '%s\n' "$BIN" + return 0 + fi - while IFS= read -r obligation; do - [[ -n "$obligation" ]] || continue + local env_file="${FKST_ENV_FILE:-$HOME/.config/fkst/aevatar.env}" + if [[ -f "$env_file" ]]; then + local env_bin + env_bin="$(sed -nE 's/^BIN=(.*)$/\1/p' "$env_file" | tail -n 1)" + if [[ -n "$env_bin" && -x "$env_bin" ]]; then + printf '%s\n' "$env_bin" + return 0 + fi + fi - if ! contains_exact_line "$drained_file" "$obligation"; then - if ! contains_exact_line "$effects_file" "$effect_marker"; then - append_exact_line_once "$effects_file" "$effect_marker" || true - emitted_effects=$((emitted_effects + 1)) - fi + if command -v fkst-framework >/dev/null 2>&1; then + command -v fkst-framework + return 0 + fi - append_exact_line_once "$drained_file" "$obligation" || true + if [[ -n "${FKST_PLATFORM_ROOT:-}" ]]; then + local sibling_bin + sibling_bin="$(cd -- "$FKST_PLATFORM_ROOT/.." 2>/dev/null && pwd)/fkst-substrate/target/debug/fkst-framework" + if [[ -x "$sibling_bin" ]]; then + printf '%s\n' "$sibling_bin" + return 0 fi - done <"$obligations_file" + fi - printf '%s\n' "$emitted_effects" + fail "fkst-framework BIN is unreachable; set BIN or FKST_ENV_FILE" } -count_pending_obligations() { - local pending_obligations=0 - local obligation="" +locked_tree="$(resolve_locked_tree)" +engine_bin="$(resolve_engine_bin)" - while IFS= read -r obligation; do - [[ -n "$obligation" ]] || continue - if ! contains_exact_line "$drained_file" "$obligation"; then - pending_obligations=$((pending_obligations + 1)) - fi - done <"$obligations_file" +[[ -f "$locked_tree/fkst.workspace.toml" ]] || + fail "locked fkst-packages tree lacks fkst.workspace.toml: $locked_tree" +[[ -d "$locked_tree/libraries" ]] || + fail "locked fkst-packages tree lacks libraries: $locked_tree" +[[ -d "$locked_tree/packages/github-devloop" ]] || + fail "locked fkst-packages tree lacks packages/github-devloop: $locked_tree" +[[ -d "$locked_tree/packages/github-proxy" ]] || + fail "locked fkst-packages tree lacks packages/github-proxy: $locked_tree" - printf '%s\n' "$pending_obligations" -} +store_dir="$(mktemp -d)" +trap 'rm -rf "$store_dir"' EXIT -produced_obligations=0 -if produce_output_obligation; then - produced_obligations=$((produced_obligations + 1)) -fi -if produce_output_obligation; then - produced_obligations=$((produced_obligations + 1)) -fi +prepare_project() { + local package_name="$1" + local selected_test="$2" + local helper_module_file="$3" + local wrapper_file="$4" + local wrapper_body="$5" + local project_dir="$store_dir/project-$package_name" + local package_dir="$project_dir/packages/$package_name" + + rm -rf "$project_dir" + mkdir -p "$project_dir/packages" + cp "$locked_tree/fkst.workspace.toml" "$project_dir/fkst.workspace.toml" + cp "$locked_tree/fkst.lock" "$project_dir/fkst.lock" + cp -R "$locked_tree/libraries" "$project_dir/libraries" + if [[ -d "$locked_tree/migration" ]]; then + cp -R "$locked_tree/migration" "$project_dir/migration" + fi + cp -R "$locked_tree/packages/$package_name" "$package_dir" + find "$package_dir/tests" -type f -name '*_test.lua' -delete + cp "$locked_tree/packages/$package_name/tests/$selected_test" "$package_dir/tests/$helper_module_file" + printf '%s\n' "$wrapper_body" >"$package_dir/tests/$wrapper_file" -[[ "$produced_obligations" == "1" ]] || - fail "expected one durable output obligation after duplicate production, found $produced_obligations" + printf '%s\n' "$project_dir" +} -first_pass_emitted_effects="$(reconcile_output_obligations)" -pending_obligations="$(count_pending_obligations)" -effect_count="$(count_exact_line "$effects_file" "$effect_marker")" +run_selected_test() { + local package_name="$1" + local selected_test="$2" + local helper_module_file="$3" + local wrapper_file="$4" + local expected_test="$5" + local wrapper_body="$6" + local project_dir package_dir rt dur report output + + project_dir="$(prepare_project "$package_name" "$selected_test" "$helper_module_file" "$wrapper_file" "$wrapper_body")" + package_dir="$project_dir/packages/$package_name" + rt="$(mktemp -d "$store_dir/runtime-$package_name.XXXXXX")" + dur="$(mktemp -d "$store_dir/durable-$package_name.XXXXXX")" + report="$rt/report.json" + + if ! output="$( + cd "$project_dir" && + FKST_RUNTIME_ROOT="$rt" FKST_DURABLE_ROOT="$dur" "$engine_bin" test \ + --project-root "$project_dir" \ + --package-root "$package_dir" \ + --report-json "$report" + )"; then + printf '%s\n' "$output" >&2 + fail "locked FKST package test failed: $package_name/$selected_test" + fi -[[ "$first_pass_emitted_effects" == "1" ]] || - fail "first reconcile pass emitted $first_pass_emitted_effects effects instead of one" -[[ "$pending_obligations" == "0" ]] || - fail "first reconcile pass left $pending_obligations pending state-output-obligation-timeout obligation(s)" -[[ "$effect_count" == "1" ]] || - fail "expected exactly one github-proxy issue-create effect for '$effect_marker', found $effect_count" + printf '%s\n' "$output" + python3 -B - "$report" "$wrapper_file" "$expected_test" <<'PY' +import json +import sys +from pathlib import Path + +report = json.loads(Path(sys.argv[1]).read_text(encoding="utf-8")) +expected_file = "tests/" + sys.argv[2] +expected_test = sys.argv[3] +summary = report.get("summary") or {} +if report.get("schema") != "fkst.test.report.v1": + raise SystemExit("bad FKST test report schema") +if int(summary.get("failed", 0)) != 0: + raise SystemExit("FKST test report contains failures") +for test in report.get("tests", []): + if ( + test.get("file") == expected_file + and test.get("name") == expected_test + and test.get("status") == "pass" + ): + break +else: + raise SystemExit(f"expected passing test not found: {expected_file}::{expected_test}") +PY +} -second_pass_emitted_effects="$(reconcile_output_obligations)" -pending_obligations="$(count_pending_obligations)" -effect_count="$(count_exact_line "$effects_file" "$effect_marker")" +owner_wrapper='local suite = require("tests.restart_timeout_obligations_owner_path") +return { + test_timeout_obligations_link_expected_decisions_and_payloads_to_frozen_parity = suite.test_timeout_obligations_link_expected_decisions_and_payloads_to_frozen_parity, +}' + +reconciler_wrapper='local suite = require("tests.timeout_reconcile_cas_parity_owner_path") +return { + test_timeout_reconcile_source_is_pre_cas_no_longer_over_budget = suite.test_timeout_reconcile_source_is_pre_cas_no_longer_over_budget, +}' + +effect_wrapper='local suite = require("tests.integration_issue_create_owner_path") +return { + test_issue_create_parent_ledger_marker_skips_create = suite.test_issue_create_request_parent_ledger_marker_skips_create, + test_issue_create_second_delivery_same_dedup_skips_create = suite.test_issue_create_request_second_delivery_same_dedup_skips_create, +}' + +run_selected_test \ + "github-devloop" \ + "restart_timeout_obligations_test.lua" \ + "restart_timeout_obligations_owner_path.lua" \ + "selected_timeout_obligations_test.lua" \ + "test_timeout_obligations_link_expected_decisions_and_payloads_to_frozen_parity" \ + "$owner_wrapper" + +run_selected_test \ + "github-devloop" \ + "timeout_reconcile_cas_parity_test.lua" \ + "timeout_reconcile_cas_parity_owner_path.lua" \ + "selected_timeout_reconcile_test.lua" \ + "test_timeout_reconcile_source_is_pre_cas_no_longer_over_budget" \ + "$reconciler_wrapper" + +run_selected_test \ + "github-proxy" \ + "integration_issue_create_test.lua" \ + "integration_issue_create_owner_path.lua" \ + "selected_issue_create_test.lua" \ + "test_issue_create_second_delivery_same_dedup_skips_create" \ + "$effect_wrapper" -[[ "$second_pass_emitted_effects" == "0" ]] || - fail "second reconcile pass emitted $second_pass_emitted_effects duplicate effect(s)" -[[ "$pending_obligations" == "0" ]] || - fail "second reconcile pass left $pending_obligations pending state-output-obligation-timeout obligation(s)" -[[ "$effect_count" == "1" ]] || - fail "expected idempotent github-proxy issue-create effect count to stay one for '$effect_marker', found $effect_count" +obligation_key="output-obligation/blocked/${source_repository}/${proposal}/${version}/${reason_class}" cat < Date: Mon, 10 Aug 2026 00:12:40 +0800 Subject: [PATCH 5/5] auto-fix refs #3361: Escalate blocked output obligation: state-output-obligation-timeout for #3344 --- .../FkstOutputObligationLivenessGuardTests.cs | 14 + .../fkst_output_obligation_liveness_guard.sh | 364 ++++++++++++++++++ 2 files changed, 378 insertions(+) diff --git a/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs b/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs index 1edb192878..452ce60b9c 100644 --- a/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs +++ b/test/Aevatar.Architecture.Tests/Rules/FkstOutputObligationLivenessGuardTests.cs @@ -27,6 +27,20 @@ public async Task StateOutputObligationTimeoutFixtureDrainsIdempotently() Assert.Contains("verified_effect_package=github-proxy", result.Output); Assert.Contains("verified_owner_test=restart_timeout_obligations_test", result.Output); Assert.Contains("verified_reconciler_test=timeout_reconcile_cas_parity_test", result.Output); + Assert.Contains("verified_from_state=impl-failed", result.Output); + Assert.Contains("verified_source_test=liveness_timeout_attempt_issue_test", result.Output); + Assert.Contains( + "verified_source_case=test_impl_failed_retry_limit_replay_decline_climbs_to_timeout_reconcile_without_seeded_timeout_markers", + result.Output); + Assert.Contains( + "verified_incident_source_case=test_incident_impl_failed_timeout_source_is_redriven_from_fixture", + result.Output); + Assert.Contains( + "verified_incident_reconciler_case=test_incident_impl_failed_timeout_reconcile_skips_stale_terminal_drop", + result.Output); + Assert.Contains( + "verified_incident_terminal_case=test_incident_blocked_output_obligation_drains_once_from_fixture", + result.Output); Assert.Contains("verified_effect_test=integration_issue_create_test", result.Output); } diff --git a/tools/ci/fkst_output_obligation_liveness_guard.sh b/tools/ci/fkst_output_obligation_liveness_guard.sh index 689b33b281..aa2cfc04ff 100644 --- a/tools/ci/fkst_output_obligation_liveness_guard.sh +++ b/tools/ci/fkst_output_obligation_liveness_guard.sh @@ -49,6 +49,13 @@ action="$(extract_attr "$timeout_line" "action")" reason_class="$(extract_attr "$timeout_line" "reason_class")" source_ref_kind="$(extract_attr "$timeout_line" "source_ref_kind")" source_ref="$(extract_attr "$timeout_line" "source_ref")" +from_state="$(extract_attr "$timeout_line" "from_state")" +from_version="$(extract_attr "$timeout_line" "from_version")" +age_minutes="$(extract_attr "$timeout_line" "age_minutes")" +budget_minutes="$(extract_attr "$timeout_line" "budget_minutes")" +attempt="$(extract_attr "$timeout_line" "attempt")" +attempt_limit="$(extract_attr "$timeout_line" "attempt_limit")" +driving_queue="$(extract_attr "$timeout_line" "driving_queue")" [[ "$state" == "blocked" ]] || fail "expected blocked state, found '$state'" [[ "$action" == "drop" ]] || fail "expected timeout reconcile action drop, found '$action'" @@ -60,11 +67,41 @@ source_ref="$(extract_attr "$timeout_line" "source_ref")" fail "expected external source_ref_kind, found '$source_ref_kind'" [[ "$source_ref" == *"#issue/"* ]] || fail "expected issue source_ref, found '$source_ref'" +[[ -n "$from_state" ]] || fail "fixture timeout-reconcile marker lacks from_state" +[[ -n "$from_version" ]] || fail "fixture timeout-reconcile marker lacks from_version" +[[ "$from_state" == "impl-failed" ]] || + fail "expected incident predecessor from_state impl-failed, found '$from_state'" +[[ "$attempt" =~ ^[0-9]+$ ]] || fail "expected numeric attempt, found '$attempt'" +[[ "$attempt_limit" =~ ^[0-9]+$ ]] || fail "expected numeric attempt_limit, found '$attempt_limit'" +[[ "$age_minutes" =~ ^[0-9]+$ ]] || fail "expected numeric age_minutes, found '$age_minutes'" +[[ "$budget_minutes" =~ ^[0-9]+$ ]] || fail "expected numeric budget_minutes, found '$budget_minutes'" +[[ "$attempt" == "$attempt_limit" ]] || + fail "expected timeout attempt to equal attempt_limit, found attempt=$attempt limit=$attempt_limit" +(( 10#$age_minutes >= 10#$budget_minutes )) || + fail "expected age_minutes to meet or exceed budget_minutes, found age=$age_minutes budget=$budget_minutes" +[[ "$driving_queue" == "devloop_ready" ]] || + fail "expected incident driving_queue devloop_ready, found '$driving_queue'" +[[ "$version" == "$from_version/timeout-reconcile/$from_state/$attempt" ]] || + fail "terminal version is not derived from from_version/from_state/attempt" source_repository="${source_ref%%#*}" [[ "$source_repository" == */* ]] || fail "expected owner/repository source_ref prefix, found '$source_ref'" +export FKST_INCIDENT_STATE="$state" +export FKST_INCIDENT_PROPOSAL="$proposal" +export FKST_INCIDENT_TERMINAL_VERSION="$version" +export FKST_INCIDENT_REASON_CLASS="$reason_class" +export FKST_INCIDENT_SOURCE_REF="$source_ref" +export FKST_INCIDENT_SOURCE_REPOSITORY="$source_repository" +export FKST_INCIDENT_FROM_STATE="$from_state" +export FKST_INCIDENT_FROM_VERSION="$from_version" +export FKST_INCIDENT_AGE_MINUTES="$age_minutes" +export FKST_INCIDENT_BUDGET_MINUTES="$budget_minutes" +export FKST_INCIDENT_ATTEMPT="$attempt" +export FKST_INCIDENT_ATTEMPT_LIMIT="$attempt_limit" +export FKST_INCIDENT_DRIVING_QUEUE="$driving_queue" + lock_fields="$( python3 -B - "$lock_file" <<'PY' import sys @@ -247,6 +284,317 @@ return { test_timeout_reconcile_source_is_pre_cas_no_longer_over_budget = suite.test_timeout_reconcile_source_is_pre_cas_no_longer_over_budget, }' +source_wrapper='local suite = require("tests.liveness_timeout_attempt_owner_path") +local base_ids = require("devloop.base_ids") +local conv_attempts = require("devloop.convergence.attempts") +local devloop_base = require("devloop.base") +local devloop_logging = require("devloop.logging") +local entity_lib = require("devloop.entity") +local entity_read_mocks = require("tests.entity_read_mock_helpers") +local reconcile_department = require("departments.reconcile.main") +local h = require("tests.devloop_helpers") + +local t = h.t +local core = h.core +local opts = h.opts + +local function required_env(name) + local value = os.getenv(name) + assert(value, name) + return value +end + +local incident = { + state = required_env("FKST_INCIDENT_STATE"), + proposal = required_env("FKST_INCIDENT_PROPOSAL"), + terminal_version = required_env("FKST_INCIDENT_TERMINAL_VERSION"), + reason_class = required_env("FKST_INCIDENT_REASON_CLASS"), + source_ref = required_env("FKST_INCIDENT_SOURCE_REF"), + source_repository = required_env("FKST_INCIDENT_SOURCE_REPOSITORY"), + from_state = required_env("FKST_INCIDENT_FROM_STATE"), + from_version = required_env("FKST_INCIDENT_FROM_VERSION"), + age_minutes = tonumber(required_env("FKST_INCIDENT_AGE_MINUTES")), + budget_minutes = tonumber(required_env("FKST_INCIDENT_BUDGET_MINUTES")), + attempt = tonumber(required_env("FKST_INCIDENT_ATTEMPT")), + attempt_limit = tonumber(required_env("FKST_INCIDENT_ATTEMPT_LIMIT")), + driving_queue = required_env("FKST_INCIDENT_DRIVING_QUEUE"), +} + +local repo, issue_number_text = base_ids.parse_proposal_id(incident.proposal) +local issue_number = tonumber(issue_number_text) +local source_ref = entity_lib.issue_source_ref(repo, issue_number_text) + +local function assert_incident_shape() + t.eq(incident.state, "blocked", "incident terminal state") + t.eq(incident.from_state, "impl-failed", "incident predecessor state") + t.eq(incident.reason_class, "state-output-obligation-timeout", "incident reason class") + t.eq(incident.driving_queue, "devloop_ready", "incident driving queue") + t.eq(incident.source_repository, repo, "incident source repository") + t.eq(incident.source_ref, source_ref.ref, "incident source ref") + t.eq(incident.terminal_version, + incident.from_version .. "/timeout-reconcile/" .. incident.from_state .. "/" .. tostring(incident.attempt), + "incident terminal version derives from predecessor") + t.eq(incident.attempt, incident.attempt_limit, "incident attempt reached limit") + t.is_true(incident.age_minutes >= incident.budget_minutes, "incident exceeded output-obligation budget") + t.is_true(issue_number ~= nil, "incident issue number parses") +end + +local function encode_json_string(value) + return h.encode_json_string(value) +end + +local function mock_repo() + t.mock_command(devloop_base.read_env_command("FKST_GITHUB_REPO"), { + stdout = repo, + stderr = "", + exit_code = 0, + }) +end + +local function mock_issue_list(updated_at) + t.mock_command(core.gh_issue_list_observe_cmd(repo), { + stdout = "[{\"number\":" .. tostring(issue_number) .. ",\"state\":\"open\",\"updated_at\":\"" + .. encode_json_string(updated_at or "2026-06-03T01:02:03Z") .. "\"}]\n", + stderr = "", + exit_code = 0, + }) +end + +local function mock_empty_pr_list() + t.mock_command(core.gh_pr_list_observe_cmd(repo), { + stdout = "[]\n", + stderr = "", + exit_code = 0, + }) +end + +local function issue_comment(body, created_at) + return { + body = body, + author_login = "fkst-test-bot", + created_at = created_at or "2026-06-03T00:00:00Z", + } +end + +local function state_comment(state_name, state_version, created_at) + return issue_comment(core.state_marker(incident.proposal, state_name, state_version), created_at) +end + +local function mock_issue_state(labels, comments, updated_at) + entity_read_mocks.mock_issue_read_forms(t, { + repo = repo, + number = issue_number, + title = "Incident issue", + body = "", + state = "OPEN", + updated_at = updated_at or "2026-06-03T01:02:03Z", + labels = labels, + comments = comments, + assignees = { "fkst-test-bot" }, + times = 1, + }) + entity_read_mocks.mock_issue_view_selector(t, { + repo = repo, + number = issue_number, + title = "Incident issue", + body = "", + state = "OPEN", + updated_at = updated_at or "2026-06-03T01:02:03Z", + labels = labels, + comments = comments, + assignees = { "fkst-test-bot" }, + author_login = "fkst-test-bot", + }, "title,updatedAt,labels,comments,state,author", 1) +end + +local function run_liveness_scan(name) + return h.run_department("departments/liveness_scan/main.lua", { + queue = "devloop_liveness_tick", + payload = { schema = "github-devloop.tick.v1" }, + ts = "2026-06-03T01:32:03Z", + }, opts(name)) +end + +local function find_raise(result, queue) + return h.find_raise(result.raises, queue) +end + +local function incident_timeout_attempt_comments(state_name, state_version, include_impl_failure) + local comments = { + state_comment(state_name, state_version, "2026-06-01T00:00:00Z"), + } + if include_impl_failure then + table.insert(comments, issue_comment(core.impl_failure_marker( + incident.proposal, + state_version, + "codex-failed", + core._max_impl_auto_retry_attempts + ))) + end + for round = 1, incident.attempt - 1 do + table.insert(comments, issue_comment(conv_attempts.timeout_attempt_marker( + incident.proposal, + state_version, + state_name, + round, + source_ref + ))) + end + return comments +end + +local function capture_cas(run) + local decisions = {} + local original = devloop_logging.log_cas_decision + devloop_logging.log_cas_decision = function(dept, proposal_id, current, from_state, to_state, outcome, reason) + table.insert(decisions, { + dept = dept, + proposal_id = proposal_id, + current = current, + from_state = from_state, + to_state = to_state, + outcome = outcome, + reason = reason, + }) + return original(dept, proposal_id, current, from_state, to_state, outcome, reason) + end + local ok, result = pcall(run) + devloop_logging.log_cas_decision = original + if not ok then + error(result, 0) + end + return result, decisions +end + +local function timeout_reconcile_event() + return { + queue = "devloop_timeout_reconcile", + payload = { + schema = "github-devloop.timeout-reconcile.v1", + proposal_id = incident.proposal, + state = incident.from_state, + issue_version = incident.from_version, + round = incident.attempt, + dedup_key = "timeout-reconcile:" .. incident.terminal_version, + source_ref = source_ref, + }, + } +end + +local function run_reconcile_event(event) + local raises = {} + local original_raise = raise + raise = function(queue, payload) + table.insert(raises, { queue = queue, payload = payload }) + end + local ok, failure = pcall(reconcile_department.pipeline, event) + raise = original_raise + return { + exit_code = ok and 0 or 1, + error = ok and nil or tostring(failure), + raises = raises, + } +end + +local function assert_no_terminal_reconcile_raise(result) + t.eq(find_raise(result, "devloop_timeout_reconcile"), nil) + t.eq(find_raise(result, "devloop_ready"), nil) +end + +return { + test_incident_impl_failed_timeout_source_is_redriven_from_fixture = function() + assert_incident_shape() + local comments = incident_timeout_attempt_comments(incident.from_state, incident.from_version, true) + mock_repo() + mock_issue_list("2026-06-03T01:02:03Z") + mock_issue_state({ "fkst-dev:enabled", "fkst-dev:impl-failed" }, comments, "2026-06-03T01:02:03Z") + mock_empty_pr_list() + + local result = run_liveness_scan("incident-impl-failed-timeout-source") + t.eq(result.exit_code, 0) + assert_no_terminal_reconcile_raise(result) + local attempt = find_raise(result, "github-proxy.github_issue_comment_request") + t.is_true(attempt ~= nil, "incident impl-failed timeout attempt is emitted") + t.is_true(attempt.payload.body:find(conv_attempts.timeout_attempt_marker( + incident.proposal, + incident.from_version, + incident.from_state, + incident.attempt, + source_ref + ), 1, true) ~= nil) + end, + + test_incident_impl_failed_timeout_reconcile_skips_stale_terminal_drop = function() + assert_incident_shape() + local comments = incident_timeout_attempt_comments(incident.from_state, incident.from_version, true) + h.mock_bot_env() + mock_repo() + mock_issue_state({ "fkst-dev:enabled", "fkst-dev:impl-failed" }, comments, "2026-06-03T01:02:03Z") + + local result, decisions = capture_cas(function() + return run_reconcile_event(timeout_reconcile_event()) + end) + + t.eq(result.exit_code, 0) + t.eq(#result.raises, 0) + local matched = false + for _, decision in ipairs(decisions) do + if decision.dept == "reconcile" + and decision.proposal_id == incident.proposal + and decision.from_state == incident.from_state + and decision.to_state == "blocked" + and decision.outcome == "skip-stale(no-longer-over-budget)" then + matched = true + end + end + t.is_true(matched, "incident timeout reconciler consumes impl-failed terminal shape") + end, + + test_incident_blocked_output_obligation_drains_once_from_fixture = function() + assert_incident_shape() + local comments = incident_timeout_attempt_comments("blocked", incident.terminal_version, false) + mock_repo() + mock_issue_list("2026-06-03T01:03:03Z") + mock_issue_state({ "fkst-dev:enabled", "fkst-dev:blocked" }, comments, "2026-06-03T01:03:03Z") + mock_empty_pr_list() + + local first = run_liveness_scan("incident-blocked-output-obligation") + t.eq(first.exit_code, 0) + t.eq(find_raise(first, "github-devloop-decompose.devloop_decompose"), nil) + t.eq(find_raise(first, "devloop_timeout_reconcile"), nil) + local exhausted = find_raise(first, "github-proxy.github_issue_comment_request") + t.is_true(exhausted ~= nil, "incident blocked output obligation emits terminal stop") + local exhausted_marker = conv_attempts.decompose_exhausted_marker( + incident.proposal, + incident.terminal_version, + incident.attempt, + source_ref + ) + t.is_true(exhausted.payload.body:find(exhausted_marker, 1, true) ~= nil) + + local exhausted_body = conv_attempts.build_decompose_exhausted_comment_request({ + kind = "issue", + repo = repo, + number = issue_number, + }, incident.proposal, { + state = "blocked", + version = incident.terminal_version, + }, source_ref, incident.attempt).body + table.insert(comments, issue_comment(exhausted_body)) + mock_repo() + mock_issue_list("2026-06-03T01:04:03Z") + mock_issue_state({ "fkst-dev:enabled", "fkst-dev:blocked" }, comments, "2026-06-03T01:04:03Z") + mock_empty_pr_list() + + local second = run_liveness_scan("incident-blocked-output-obligation-second") + t.eq(second.exit_code, 0) + t.eq(#second.raises, 0) + end, + + test_impl_failed_retry_limit_replay_decline_climbs_to_timeout_reconcile_without_seeded_timeout_markers = + suite.test_impl_failed_retry_limit_replay_decline_climbs_to_timeout_reconcile_without_seeded_timeout_markers, +}' + effect_wrapper='local suite = require("tests.integration_issue_create_owner_path") return { test_issue_create_parent_ledger_marker_skips_create = suite.test_issue_create_request_parent_ledger_marker_skips_create, @@ -261,6 +609,14 @@ run_selected_test \ "test_timeout_obligations_link_expected_decisions_and_payloads_to_frozen_parity" \ "$owner_wrapper" +run_selected_test \ + "github-devloop" \ + "liveness_timeout_attempt_issue_test.lua" \ + "liveness_timeout_attempt_owner_path.lua" \ + "selected_incident_timeout_source_test.lua" \ + "test_incident_impl_failed_timeout_source_is_redriven_from_fixture" \ + "$source_wrapper" + run_selected_test \ "github-devloop" \ "timeout_reconcile_cas_parity_test.lua" \ @@ -285,6 +641,14 @@ locked_fkst_package_rev=$lock_rev output_obligation=$obligation_key verified_owner_package=github-devloop verified_owner_test=restart_timeout_obligations_test +verified_from_state=$from_state +verified_from_version=$from_version +verified_terminal_version=$version +verified_source_test=liveness_timeout_attempt_issue_test +verified_source_case=test_impl_failed_retry_limit_replay_decline_climbs_to_timeout_reconcile_without_seeded_timeout_markers +verified_incident_source_case=test_incident_impl_failed_timeout_source_is_redriven_from_fixture +verified_incident_reconciler_case=test_incident_impl_failed_timeout_reconcile_skips_stale_terminal_drop +verified_incident_terminal_case=test_incident_blocked_output_obligation_drains_once_from_fixture verified_reconciler_test=timeout_reconcile_cas_parity_test verified_effect_package=github-proxy verified_effect_test=integration_issue_create_test