From 76e10a4bae90835728fdd4f9a084001ab62d03a9 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 13:15:14 +0900 Subject: [PATCH 1/7] Inject clocks for time-sensitive paths (#1617) --- changelog.d/unreleased/1617.fixed.md | 20 ++++++++++++++ src/CodeIndex/Cli/GlobalToolLog.cs | 3 ++- src/CodeIndex/Cli/ProgramRunner.cs | 5 ++-- src/CodeIndex/Cli/QueryCommandRunner.cs | 9 ++++--- src/CodeIndex/Cli/SuggestionStore.cs | 14 ++++++---- src/CodeIndex/Mcp/McpServer.cs | 29 ++++++++++++++------- src/CodeIndex/Mcp/McpToolHandlers.cs | 8 +++--- tests/CodeIndex.Tests/ManualTimeProvider.cs | 16 ++++++++++++ tests/CodeIndex.Tests/McpServerTests.cs | 6 +++-- 9 files changed, 83 insertions(+), 27 deletions(-) create mode 100644 changelog.d/unreleased/1617.fixed.md create mode 100644 tests/CodeIndex.Tests/ManualTimeProvider.cs diff --git a/changelog.d/unreleased/1617.fixed.md b/changelog.d/unreleased/1617.fixed.md new file mode 100644 index 0000000000..9b728d6f37 --- /dev/null +++ b/changelog.d/unreleased/1617.fixed.md @@ -0,0 +1,20 @@ +--- +category: fixed +issues: + - 1617 +affected: + - src/CodeIndex/Cli/GlobalToolLog.cs + - src/CodeIndex/Cli/ProgramRunner.cs + - src/CodeIndex/Cli/QueryCommandRunner.cs + - src/CodeIndex/Mcp/McpServer.cs + - src/CodeIndex/Mcp/McpToolHandlers.cs + - tests/CodeIndex.Tests/McpServerTests.cs +--- + +## English + +- **Time-sensitive CLI and MCP paths now use injectable clocks (#1617)** — status age calculations, query trace/log naming, MCP ping timestamps, and MCP suggestion persistence can now be driven by `TimeProvider` for deterministic tests. + +## 日本語 + +- **時刻に依存する CLI / MCP 経路が注入可能な clock を使うようになりました (#1617)** — status の経過時間計算、query trace / log のファイル名、MCP ping の timestamp、MCP suggestion の永続化を `TimeProvider` で決定論的にテストできるようになりました。 diff --git a/src/CodeIndex/Cli/GlobalToolLog.cs b/src/CodeIndex/Cli/GlobalToolLog.cs index 048a415ea2..dff462de5f 100644 --- a/src/CodeIndex/Cli/GlobalToolLog.cs +++ b/src/CodeIndex/Cli/GlobalToolLog.cs @@ -18,6 +18,7 @@ internal static class GlobalToolLog internal const string LogRetainEnvironmentVariable = "CDIDX_LOG_RETAIN"; internal const string LogMaxSizeMbEnvironmentVariable = "CDIDX_LOG_MAX_SIZE_MB"; private const string RedactedValue = ""; + internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; private static readonly AsyncLocal CurrentSession = new(); private static readonly Regex SensitiveAssignmentPattern = new( @"^(?--?[^=\s]*(?:token|password|passwd|pwd|secret|auth|apikey|api-key|access-key|credential)[^=\s]*)=(?.+)$", @@ -377,7 +378,7 @@ private static string GetHomeDirectoryOrOriginal(string original) private static string ResolveLogPath(string logDirectory, LogOptions options) { - var date = DateTime.UtcNow.ToString("yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture); + var date = TimeProvider.GetUtcNow().UtcDateTime.ToString("yyyyMMdd", System.Globalization.CultureInfo.InvariantCulture); if (options.MaxSizeBytes <= 0) return Path.Combine(logDirectory, $"stderr-{date}.log"); diff --git a/src/CodeIndex/Cli/ProgramRunner.cs b/src/CodeIndex/Cli/ProgramRunner.cs index f7dcba0acb..7c9d2e1cf9 100644 --- a/src/CodeIndex/Cli/ProgramRunner.cs +++ b/src/CodeIndex/Cli/ProgramRunner.cs @@ -13,6 +13,7 @@ namespace CodeIndex.Cli; internal static class ProgramRunner { internal const string QuietEnvironmentVariable = "CDIDX_QUIET"; + internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; internal static int Run( string[] args, @@ -86,7 +87,7 @@ internal static int Run( using var jsonAnsiScope = ConsoleUi.SuppressAnsiForJsonOutput(ContainsJsonOutputFlag(args)); var commandStopwatch = Stopwatch.StartNew(); - var commandStartTimestamp = DateTimeOffset.UtcNow; + var commandStartTimestamp = TimeProvider.GetUtcNow(); var versionPinExit = CheckWorkspaceVersionPin(appVersion, configStartDirectory ?? Environment.CurrentDirectory, strictVersion); if (versionPinExit != CommandExitCodes.Success) return versionPinExit; @@ -993,7 +994,7 @@ private static void EmitQueryTrace(string mode, string commandName, string[] sub var directory = GlobalToolLog.ResolveLogDirectoryForStatus(); Directory.CreateDirectory(directory); - var path = Path.Combine(directory, $"query-trace-{DateTime.UtcNow:yyyyMMdd}.jsonl"); + var path = Path.Combine(directory, $"query-trace-{TimeProvider.GetUtcNow().UtcDateTime:yyyyMMdd}.jsonl"); File.AppendAllText(path, payload + Environment.NewLine); } catch diff --git a/src/CodeIndex/Cli/QueryCommandRunner.cs b/src/CodeIndex/Cli/QueryCommandRunner.cs index a86e79975a..f6c679c5ae 100644 --- a/src/CodeIndex/Cli/QueryCommandRunner.cs +++ b/src/CodeIndex/Cli/QueryCommandRunner.cs @@ -21,9 +21,12 @@ public static class QueryCommandRunner internal const string DefaultMaxLineWidthEnvironmentVariable = "CDIDX_DEFAULT_MAX_LINE_WIDTH"; internal const string StaleAfterEnvironmentVariable = "CDIDX_STALE_AFTER"; internal static readonly TimeSpan DefaultStaleAfter = TimeSpan.FromHours(24); + internal static TimeProvider TimeProvider { get; set; } = TimeProvider.System; [ThreadStatic] private static DbReader? s_batchReader; + private static DateTime GetUtcNow() => TimeProvider.GetUtcNow().UtcDateTime; + // Cap OR-joined `symbols` names well below SQLite's 1000 expression-tree depth so oversized // batches fail fast with a clear usage error instead of a confusing SQLite exception. // OR 結合の `symbols` 名は SQLite の式木深さ上限 1000 を十分下回る値で頭打ちにし、 @@ -2623,7 +2626,7 @@ public static int RunStatus(string[] cmdArgs, JsonSerializerOptions jsonOptions, : null; status.StaleAfterSeconds = (long)Math.Round(staleAfter.Value.TotalSeconds, MidpointRounding.AwayFromZero); if (status.IndexedAt.HasValue) - status.IndexAgeSeconds = Math.Max(0, (long)Math.Round((DateTime.UtcNow - status.IndexedAt.Value).TotalSeconds, MidpointRounding.AwayFromZero)); + status.IndexAgeSeconds = Math.Max(0, (long)Math.Round((GetUtcNow() - status.IndexedAt.Value).TotalSeconds, MidpointRounding.AwayFromZero)); } // Attach runtime metadata / ランタイムメタデータを付加 status.SymbolKinds = reader.GetSymbolKindCounts(); @@ -6139,7 +6142,7 @@ private static void WriteZeroResultHints(QueryCommandOptions options, DbReader r if (freshness.IndexedAt.HasValue) { - var age = DateTime.UtcNow - freshness.IndexedAt.Value; + var age = GetUtcNow() - freshness.IndexedAt.Value; if (age > staleAfter.Value) Console.Error.WriteLine($"Hint: the index is {FormatDuration(age)} old (threshold: {FormatDuration(staleAfter.Value)}). Run 'cdidx index ' to refresh."); } @@ -6601,7 +6604,7 @@ private static void WriteStatusAge(StatusResult status, TimeSpan staleAfter) if (!status.IndexedAt.HasValue) return; - var age = DateTime.UtcNow - status.IndexedAt.Value; + var age = GetUtcNow() - status.IndexedAt.Value; if (age < TimeSpan.Zero) age = TimeSpan.Zero; diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index 904b8aecc0..b0f838843d 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -21,6 +21,7 @@ public class SuggestionStore { private readonly string _filePath; private readonly string _lockPath; + private readonly TimeProvider _timeProvider; private static readonly TimeSpan s_inFlightSubmitRetryDelay = TimeSpan.FromMinutes(1); internal const FileShare StreamingReadFileShare = FileShare.ReadWrite | FileShare.Delete; internal const string DedupThresholdEnvironmentVariable = "CDIDX_SUGGESTION_DEDUP_THRESHOLD"; @@ -81,13 +82,14 @@ static SuggestionStore() /// Database filename without extension (optional, defaults to "codeindex"). /// 拡張子なしのデータベースファイル名(任意、デフォルトは "codeindex")。 /// - public SuggestionStore(string cdidxDir, string? dbName = null) + public SuggestionStore(string cdidxDir, string? dbName = null, TimeProvider? timeProvider = null) { // Derive a safe store filename from the DB identity. // DB固有の安全なストアファイル名を導出する。 var safeName = string.IsNullOrWhiteSpace(dbName) ? "codeindex" : dbName; _filePath = Path.Combine(cdidxDir, $"suggestions-{safeName}.json"); _lockPath = Path.Combine(cdidxDir, $"suggestions-{safeName}.lock"); + _timeProvider = timeProvider ?? TimeProvider.System; } /// @@ -204,7 +206,7 @@ public async Task TryAddAndSubmitAsync( var current = found!; if (!alreadySubmitted && submitToGitHub != null && ShouldAttemptSubmit(current)) { - var attemptedAt = DateTime.UtcNow; + var attemptedAt = GetUtcNow(); StampSubmitAttempt(current, attemptedAt, null, attemptedAt.Add(s_inFlightSubmitRetryDelay)); SaveUnlocked(existing); return new SubmitReservation( @@ -375,7 +377,7 @@ public void MarkSubmitted(string hash, string issueUrl) if (record == null) return; - MarkSubmitted(record, issueUrl, DateTime.UtcNow); + MarkSubmitted(record, issueUrl, GetUtcNow()); SaveUnlocked(all); }); } @@ -728,14 +730,16 @@ private static void MarkSubmitted(SuggestionRecord record, string issueUrl, Date record.GitHubIssueUrl = null; } - private static bool ShouldAttemptSubmit(SuggestionRecord record) + private bool ShouldAttemptSubmit(SuggestionRecord record) { if (record.NextRetryAt == null) return true; - return record.NextRetryAt.Value <= DateTime.UtcNow; + return record.NextRetryAt.Value <= GetUtcNow(); } + private DateTime GetUtcNow() => _timeProvider.GetUtcNow().UtcDateTime; + private static void StampSubmitAttempt(SuggestionRecord record, DateTime timestamp, string? error, DateTime? nextRetryAt) { record.LastSubmitAttempt = timestamp; diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index f1fedbf032..9a6173dc9f 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -33,6 +33,7 @@ public partial class McpServer : IDisposable private readonly Func _serializeResponse; private readonly IMcpAuthenticator _authenticator; private readonly McpToolFilter _toolFilter; + private readonly TimeProvider _timeProvider; // Bounds the number of MCP tool calls in flight at once so an unbounded burst of // requests cannot exhaust memory or wedge the SQLite reader lock (#1567). The // stdio / HTTP loop today only ever has one frame in flight, but the gate @@ -166,17 +167,17 @@ public partial class McpServer : IDisposable internal static readonly TimeSpan DefaultEofPostCancelDrainTimeout = TimeSpan.FromSeconds(5); public McpServer(string dbPath, string version, bool dbPathExplicit = false) - : this(dbPath, version, dbPathExplicit, null, null, null, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, null, null, null, null, DefaultMaxConcurrency, null) { } public McpServer(string dbPath, string version, bool dbPathExplicit, IMcpAuthenticator authenticator) - : this(dbPath, version, dbPathExplicit, null, authenticator, null, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, null, authenticator, null, null, DefaultMaxConcurrency, null) { } public McpServer(string dbPath, string version, bool dbPathExplicit, McpToolFilter? toolFilter) - : this(dbPath, version, dbPathExplicit, null, null, toolFilter, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, null, null, toolFilter, null, DefaultMaxConcurrency, null) { } @@ -184,22 +185,22 @@ public McpServer(string dbPath, string version, bool dbPathExplicit, McpToolFilt // do not need a custom authenticator or tool filter. // serializer 注入だけが必要な既存テスト向けの内部互換 entry。 internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse) - : this(dbPath, version, dbPathExplicit, serializeResponse, null, null, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, null, null, null, DefaultMaxConcurrency, null) { } internal McpServer(string dbPath, string version, bool dbPathExplicit, AuditLogSink? auditLog) - : this(dbPath, version, dbPathExplicit, null, null, null, auditLog, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, null, null, null, auditLog, DefaultMaxConcurrency, null) { } internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator) - : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, null, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, null, null, DefaultMaxConcurrency, null) { } internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator, McpToolFilter? toolFilter) - : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, null, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, null, DefaultMaxConcurrency, null) { } @@ -207,7 +208,7 @@ internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator, McpToolFilter? toolFilter, int maxConcurrency) - : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, null, maxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, null, maxConcurrency, null) { } @@ -217,16 +218,21 @@ internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator, McpToolFilter? toolFilter, AuditLogSink? auditLog) - : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, auditLog, DefaultMaxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, auditLog, DefaultMaxConcurrency, null) { } internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator, McpToolFilter? toolFilter, AuditLogSink? auditLog, int maxConcurrency) + : this(dbPath, version, dbPathExplicit, serializeResponse, authenticator, toolFilter, auditLog, maxConcurrency, null) + { + } + + internal McpServer(string dbPath, string version, bool dbPathExplicit, Func? serializeResponse, IMcpAuthenticator? authenticator, McpToolFilter? toolFilter, AuditLogSink? auditLog, int maxConcurrency, TimeProvider? timeProvider) { if (maxConcurrency < 1) throw new ArgumentOutOfRangeException(nameof(maxConcurrency), maxConcurrency, "MCP concurrency cap must be at least 1."); @@ -242,6 +248,7 @@ internal McpServer(string dbPath, string version, bool dbPathExplicit, Func node.ToJsonString(_jsonOptions)); _authenticator = authenticator ?? LocalStdioAuthenticator.Instance; _toolFilter = toolFilter ?? McpToolFilter.FromEnvironment(); + _timeProvider = timeProvider ?? TimeProvider.System; _auditLog = auditLog; RateLimiter = new RateLimiter(RateLimiterOptions.FromEnvironment()); _concurrencyGate = new SemaphoreSlim(maxConcurrency, maxConcurrency); @@ -296,6 +303,8 @@ internal void OverrideRateLimiterForTests(RateLimiter limiter) /// internal int MaxConcurrency { get; } + private DateTime GetUtcNow() => _timeProvider.GetUtcNow().UtcDateTime; + internal TimeSpan RequestTimeout { get => _requestTimeout; diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index 55b69080f3..d54adcb15d 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -2638,7 +2638,7 @@ private JsonNode ExecutePing(JsonNode? id) var payload = new JsonObject { ["version"] = _version, - ["timestamp"] = DateTime.UtcNow.ToString("O"), + ["timestamp"] = GetUtcNow().ToString("O"), ["db_path"] = _dbPath, ["db_exists"] = File.Exists(LongPath.EnsureWindowsPrefix(_dbPath)), }; @@ -3035,7 +3035,7 @@ void WriteProjectRootOnce() { var headBranch = GitHelper.TryGetHeadBranch(projectPath); var timestamp = currentHeadCommit != null - ? DateTime.UtcNow.ToString("o", System.Globalization.CultureInfo.InvariantCulture) + ? GetUtcNow().ToString("o", System.Globalization.CultureInfo.InvariantCulture) : null; writer.SetMeta(DbContext.IndexedHeadShaMetaKey, currentHeadCommit); writer.SetMeta(DbContext.IndexedHeadBranchMetaKey, headBranch); @@ -3257,7 +3257,7 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo // Derive DB identity for scoped suggestion storage. // スコープ付き提案蓄積のため DB identity を導出。 var dbName = Path.GetFileNameWithoutExtension(_dbPath); - var store = new SuggestionStore(cdidxDir, dbName); + var store = new SuggestionStore(cdidxDir, dbName, _timeProvider); var record = new SuggestionRecord { Category = category, @@ -3265,7 +3265,7 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo Description = description, Context = context, Hash = hash, - CreatedAt = DateTime.UtcNow, + CreatedAt = GetUtcNow(), CreatedByAgent = ResolveSuggestionAgent(), SessionId = _sessionId, ClientVersion = _version, diff --git a/tests/CodeIndex.Tests/ManualTimeProvider.cs b/tests/CodeIndex.Tests/ManualTimeProvider.cs new file mode 100644 index 0000000000..c0135b32dc --- /dev/null +++ b/tests/CodeIndex.Tests/ManualTimeProvider.cs @@ -0,0 +1,16 @@ +namespace CodeIndex.Tests; + +internal sealed class ManualTimeProvider : TimeProvider +{ + internal static readonly DateTimeOffset FixtureUtcNow = DateTimeOffset.UnixEpoch.AddDays(20_000); + private DateTimeOffset _utcNow; + + public ManualTimeProvider(DateTimeOffset utcNow) + { + _utcNow = utcNow.ToUniversalTime(); + } + + public override DateTimeOffset GetUtcNow() => _utcNow; + + public void SetUtcNow(DateTimeOffset utcNow) => _utcNow = utcNow.ToUniversalTime(); +} diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 79286769e6..400e35d1c8 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -5951,14 +5951,16 @@ public void ToolsCall_Status_ExplicitExternalCodeIndexDb_UsesPersistedProjectRoo [Fact] public void ToolsCall_Ping_ReturnsVersionAndTimestamp() { + var clock = new ManualTimeProvider(new DateTimeOffset(2032, 4, 5, 6, 7, 8, TimeSpan.Zero)); + using var server = new McpServer(_dbPath, ConsoleUi.LoadVersion(), false, null, null, null, null, McpServer.DefaultMaxConcurrency, clock); var request = JsonNode.Parse("""{"jsonrpc":"2.0","id":1,"method":"tools/call","params":{"name":"ping","arguments":{}}}""")!; - var response = _server.HandleMessage(request)!; + var response = server.HandleMessage(request)!; var text = response["result"]!["content"]![0]!["text"]!.GetValue(); Assert.Contains("cdidx v", text); Assert.Contains("is ready", text); Assert.NotNull(response["result"]!["structuredContent"]!["version"]); - Assert.NotNull(response["result"]!["structuredContent"]!["timestamp"]); + Assert.Equal(clock.GetUtcNow().UtcDateTime.ToString("O"), response["result"]!["structuredContent"]!["timestamp"]!.GetValue()); Assert.NotNull(response["result"]!["structuredContent"]!["db_exists"]); } From 30b125af2d55ac782927349c8020631deb4635ad Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 13:18:01 +0900 Subject: [PATCH 2/7] Stamp suggestion creation on persistence (#1618) --- changelog.d/unreleased/1618.fixed.md | 16 +++++++ src/CodeIndex/Cli/SuggestionStore.cs | 4 ++ src/CodeIndex/Mcp/McpToolHandlers.cs | 1 - tests/CodeIndex.Tests/SuggestionStoreTests.cs | 45 ++++++++++++++++--- 4 files changed, 58 insertions(+), 8 deletions(-) create mode 100644 changelog.d/unreleased/1618.fixed.md diff --git a/changelog.d/unreleased/1618.fixed.md b/changelog.d/unreleased/1618.fixed.md new file mode 100644 index 0000000000..4872ca681e --- /dev/null +++ b/changelog.d/unreleased/1618.fixed.md @@ -0,0 +1,16 @@ +--- +category: fixed +issues: + - 1618 +affected: + - src/CodeIndex/Cli/SuggestionStore.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs +--- + +## English + +- **Suggestion `CreatedAt` is now stamped at persistence time (#1618)** — new suggestions receive their creation timestamp inside the store's locked write path, aligning the recorded time with when the suggestion enters the local store. + +## 日本語 + +- **suggestion の `CreatedAt` を永続化時に記録するようになりました (#1618)** — 新規 suggestion の作成時刻は store のロックされた書き込み経路内で付与され、ローカル store に入った時刻と一致するようになりました。 diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index b0f838843d..2190d99397 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -125,6 +125,7 @@ public bool TryAdd(SuggestionRecord record) if (FindDuplicate(existing, record, ResolveDedupThreshold()).Record != null) return false; + StampCreatedAt(record); existing.Add(record); SaveUnlocked(existing); return true; @@ -198,6 +199,7 @@ public async Task TryAddAndSubmitAsync( if (isNew) { + StampCreatedAt(record); existing.Add(record); SaveUnlocked(existing); found = record; @@ -738,6 +740,8 @@ private bool ShouldAttemptSubmit(SuggestionRecord record) return record.NextRetryAt.Value <= GetUtcNow(); } + private void StampCreatedAt(SuggestionRecord record) => record.CreatedAt = GetUtcNow(); + private DateTime GetUtcNow() => _timeProvider.GetUtcNow().UtcDateTime; private static void StampSubmitAttempt(SuggestionRecord record, DateTime timestamp, string? error, DateTime? nextRetryAt) diff --git a/src/CodeIndex/Mcp/McpToolHandlers.cs b/src/CodeIndex/Mcp/McpToolHandlers.cs index d54adcb15d..18d58d010d 100644 --- a/src/CodeIndex/Mcp/McpToolHandlers.cs +++ b/src/CodeIndex/Mcp/McpToolHandlers.cs @@ -3265,7 +3265,6 @@ private async Task ExecuteSuggestImprovementAsync(JsonNode? id, JsonNo Description = description, Context = context, Hash = hash, - CreatedAt = GetUtcNow(), CreatedByAgent = ResolveSuggestionAgent(), SessionId = _sessionId, ClientVersion = _version, diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index 584f0dd5a6..22f90a0f13 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -89,6 +89,20 @@ public void TryAdd_NewSuggestion_ReturnsTrue() Assert.True(_store.TryAdd(record)); } + [Fact] + public void TryAdd_StampsCreatedAtFromInjectedClockWhenPersisted() + { + var clock = new ManualTimeProvider(new DateTimeOffset(2030, 2, 3, 4, 5, 6, TimeSpan.Zero)); + var store = new SuggestionStore(_tempDir, timeProvider: clock); + var record = MakeRecord("symbol_extraction", "csharp", "Missing record support"); + record.CreatedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + Assert.True(store.TryAdd(record)); + + var saved = Assert.Single(store.LoadAll()); + Assert.Equal(clock.GetUtcNow().UtcDateTime, saved.CreatedAt); + } + [Fact] public void TryAdd_Duplicate_ReturnsFalse() { @@ -140,6 +154,22 @@ public void TryAddAndSubmit_FuzzyDuplicate_ReturnsMatchedHashAndScore() Assert.True(second.DuplicateScore >= SuggestionStore.DefaultDedupThreshold); } + [Fact] + public void TryAddAndSubmit_UsesInjectedClockForRetryAndSubmissionTimestamps() + { + var clock = new ManualTimeProvider(new DateTimeOffset(2031, 3, 4, 5, 6, 7, TimeSpan.Zero)); + var store = new SuggestionStore(_tempDir, timeProvider: clock); + var record = MakeRecord("other", null, "submit me"); + + var result = store.TryAddAndSubmit(record, _ => SuggestionStore.SubmitAttemptResult.Success("https://github.com/Widthdom/CodeIndex/issues/1")); + + Assert.True(result.IsNew); + var saved = Assert.Single(store.LoadAll()); + Assert.Equal(clock.GetUtcNow().UtcDateTime, saved.CreatedAt); + Assert.Equal(clock.GetUtcNow().UtcDateTime, saved.LastSubmitAttempt); + Assert.Equal(clock.GetUtcNow().UtcDateTime, saved.LastSyncedAt); + } + [Fact] public void TryAddAndSubmit_FuzzyDuplicate_IgnoresClosedDiagnosticStderr() { @@ -457,18 +487,19 @@ public void LoadByStatus_ReturnsOnlyMatchingLifecycleState() [Fact] public void LoadSince_ReturnsSuggestionsAtOrAfterThreshold() { + var clock = new ManualTimeProvider(ManualTimeProvider.FixtureUtcNow); + var store = new SuggestionStore(_tempDir, timeProvider: clock); var older = MakeRecord("other", null, "Older suggestion"); - older.CreatedAt = new DateTime(2026, 5, 1, 9, 0, 0, DateTimeKind.Utc); var boundary = MakeRecord("other", null, "Boundary suggestion"); - boundary.CreatedAt = new DateTime(2026, 5, 2, 9, 0, 0, DateTimeKind.Utc); var newer = MakeRecord("other", null, "Newer suggestion"); - newer.CreatedAt = new DateTime(2026, 5, 3, 9, 0, 0, DateTimeKind.Utc); - _store.TryAdd(older); - _store.TryAdd(boundary); - _store.TryAdd(newer); + store.TryAdd(older); + clock.SetUtcNow(ManualTimeProvider.FixtureUtcNow.AddDays(1)); + store.TryAdd(boundary); + clock.SetUtcNow(ManualTimeProvider.FixtureUtcNow.AddDays(2)); + store.TryAdd(newer); - var loaded = _store.LoadSince(new DateTimeOffset(2026, 5, 2, 9, 0, 0, TimeSpan.Zero)); + var loaded = store.LoadSince(ManualTimeProvider.FixtureUtcNow.AddDays(1)); Assert.Equal(new[] { boundary.Hash, newer.Hash }, loaded.Select(s => s.Hash)); } From 02ec5376d3a13fcf7588c18ca2a83c6890598eea Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 13:24:44 +0900 Subject: [PATCH 3/7] Use explicit test clocks for time fixtures (#2034) --- changelog.d/unreleased/2034.fixed.md | 17 +++++++++++++++++ tests/CodeIndex.Tests/ConcurrencyTests.cs | 14 +++++++------- .../LegacySchemaMigrationTests.cs | 3 ++- tests/CodeIndex.Tests/McpServerTests.cs | 4 ++-- 4 files changed, 28 insertions(+), 10 deletions(-) create mode 100644 changelog.d/unreleased/2034.fixed.md diff --git a/changelog.d/unreleased/2034.fixed.md b/changelog.d/unreleased/2034.fixed.md new file mode 100644 index 0000000000..f74648aae8 --- /dev/null +++ b/changelog.d/unreleased/2034.fixed.md @@ -0,0 +1,17 @@ +--- +category: fixed +issues: + - 2034 +affected: + - tests/CodeIndex.Tests/ManualTimeProvider.cs + - tests/CodeIndex.Tests/McpServerTests.cs + - tests/CodeIndex.Tests/SuggestionStoreTests.cs +--- + +## English + +- **Time-dependent tests now use an explicit test clock (#2034)** — MCP timestamp and suggestion lifecycle coverage use `ManualTimeProvider` instead of depending on wall-clock time or hardcoded fixture dates. + +## 日本語 + +- **時刻依存テストが明示的な test clock を使うようになりました (#2034)** — MCP timestamp と suggestion lifecycle のカバレッジは wall-clock や固定 fixture 日付に依存せず `ManualTimeProvider` を使うようになりました。 diff --git a/tests/CodeIndex.Tests/ConcurrencyTests.cs b/tests/CodeIndex.Tests/ConcurrencyTests.cs index e2863a93c9..c2920c072f 100644 --- a/tests/CodeIndex.Tests/ConcurrencyTests.cs +++ b/tests/CodeIndex.Tests/ConcurrencyTests.cs @@ -32,7 +32,7 @@ public async Task ConcurrentReads_DoNotBlock() var fileId = writer.UpsertFile(new FileRecord { Path = "src/app.cs", Lang = "csharp", Size = 100, Lines = 10, - Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = "abc", }); writer.InsertChunks([new ChunkRecord { FileId = fileId, ChunkIndex = 0, StartLine = 1, EndLine = 10, Content = "public class App { }" }]); @@ -126,7 +126,7 @@ public async Task GetStatus_ReferencesAndFilesStaySnapshotConsistent_UnderConcur var fileId = writer.UpsertFile(new FileRecord { Path = $"src/seed{seedIndex}.cs", Lang = "csharp", Size = 100, Lines = 10, - Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = $"seed{seedIndex}", }); writer.InsertReferences(BuildReferenceBatch(fileId, $"seed{seedIndex}", refsPerFile)); @@ -155,7 +155,7 @@ public async Task GetStatus_ReferencesAndFilesStaySnapshotConsistent_UnderConcur var fileId = w.UpsertFile(new FileRecord { Path = $"src/added{extra}.cs", Lang = "csharp", Size = 100, Lines = 10, - Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = $"added{extra}", }); w.InsertReferences(BuildReferenceBatch(fileId, $"added{extra}", refsPerFile)); @@ -217,8 +217,8 @@ public async Task AnalyzeSymbol_RefsCallersAndFreshnessStaySnapshotConsistent_Un // file A のみで freshness = T0、2 refs なら file B も存在し modified = T1 > T0 // なので freshness = T1。`SearchReferences` / `GetCallers` と // `GetWorkspaceFreshness` の間で writer が commit すると食い違う。 - var fileAModified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); - var fileBModified = new DateTime(2026, 6, 1, 0, 0, 0, DateTimeKind.Utc); + var fileAModified = ManualTimeProvider.FixtureUtcNow.UtcDateTime; + var fileBModified = ManualTimeProvider.FixtureUtcNow.AddDays(1).UtcDateTime; var writer = new DbWriter(_db.Connection); var fileAId = writer.UpsertFile(new FileRecord { @@ -362,7 +362,7 @@ public async Task GetRepoMap_FreshnessAndEntrypointsStaySnapshotConsistent_Under // 2. `src/Program.cs`(`Main()` 関数を持つ)の存在は freshness と整合する。 // `getFreshness` と `GetEntrypoints` の torn read で破れる。 var writer = new DbWriter(_db.Connection); - var baselineModified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc); + var baselineModified = ManualTimeProvider.FixtureUtcNow.UtcDateTime; for (var seedIndex = 0; seedIndex < 3; seedIndex++) { writer.UpsertFile(new FileRecord @@ -543,7 +543,7 @@ public async Task BeginTransaction_SharedWriterSerializesConcurrentScopes() Lang = "csharp", Size = 10, Lines = 1, - Modified = new DateTime(2025, 1, 1, 0, 0, 0, DateTimeKind.Utc), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = $"{worker}_{i}", }); txn.Commit(); diff --git a/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs b/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs index e6eac69e62..e67db4e9aa 100644 --- a/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs +++ b/tests/CodeIndex.Tests/LegacySchemaMigrationTests.cs @@ -91,7 +91,8 @@ line INTEGER // Seed one file and a handful of symbols on the legacy schema. // レガシースキーマに対してファイル1つとシンボルを数件投入。 - Exec(conn, "INSERT INTO files (id, path, lang, size, lines, modified) VALUES (1, 'src/Legacy.cs', 'csharp', 200, 20, '2025-06-01 00:00:00')"); + var modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime.ToString("yyyy-MM-dd HH:mm:ss", System.Globalization.CultureInfo.InvariantCulture); + Exec(conn, $"INSERT INTO files (id, path, lang, size, lines, modified) VALUES (1, 'src/Legacy.cs', 'csharp', 200, 20, '{modified}')"); Exec(conn, @"INSERT INTO chunks (file_id, chunk_index, start_line, end_line, content) VALUES (1, 0, 1, 20, 'class Legacy' || char(10) || '{' || char(10) || diff --git a/tests/CodeIndex.Tests/McpServerTests.cs b/tests/CodeIndex.Tests/McpServerTests.cs index 400e35d1c8..c3ea0bdfbb 100644 --- a/tests/CodeIndex.Tests/McpServerTests.cs +++ b/tests/CodeIndex.Tests/McpServerTests.cs @@ -43,7 +43,7 @@ public McpServerTests() Lang = "csharp", Size = 200, Lines = 10, - Modified = new DateTime(2024, 1, 1), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = "abc123", }); writer.InsertChunks([new ChunkRecord @@ -92,7 +92,7 @@ private void InsertIndexedFile(string path, string lang, string content, bool ge Lang = lang, Size = normalized.Length, Lines = lines.Length, - Modified = new DateTime(2024, 1, 1), + Modified = ManualTimeProvider.FixtureUtcNow.UtcDateTime, Checksum = Guid.NewGuid().ToString("N"), Generated = generated, }); From 8fbb6c5d6fd15f2ef09c0f21ca36ae82d3b1fd60 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:17:02 +0900 Subject: [PATCH 4/7] Stabilize diagnostic stderr writes in CI --- src/CodeIndex/Cli/ConsoleUi.cs | 14 +++++++++++++ .../Cli/IndexCommandRunner.FullScan.cs | 12 +++++------ src/CodeIndex/Mcp/McpServer.cs | 2 +- tests/CodeIndex.Tests/ConsoleUiTests.cs | 20 +++++++++++++++++++ 4 files changed, 41 insertions(+), 7 deletions(-) diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index d9b935293e..6da0e09900 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -156,6 +156,20 @@ internal static void EnsureConsoleWritersSynchronized() } } + internal static void TryWriteErrorLine(string? value = null) + { + try + { + if (value == null) + Console.Error.WriteLine(); + else + Console.Error.WriteLine(value); + } + catch (ObjectDisposedException) + { + } + } + internal static IDisposable SuppressAnsiForJsonOutput(bool enabled) { if (!enabled) diff --git a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs index 808ecd7335..2959dcbf66 100644 --- a/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs +++ b/src/CodeIndex/Cli/IndexCommandRunner.FullScan.cs @@ -433,7 +433,7 @@ void WriteJsonLiveness(string message) if (!options.Json || options.Quiet) return; - Console.Error.WriteLine($"cdidx: {message}"); + ConsoleUi.TryWriteErrorLine($"cdidx: {message}"); } (CancellationTokenSource Cts, Task Task)? StartJsonPhaseHeartbeat(string phase, Func? detailProvider = null) @@ -461,7 +461,7 @@ void WriteJsonLiveness(string message) var detail = detailProvider?.Invoke(); var suffix = string.IsNullOrWhiteSpace(detail) ? string.Empty : $": {detail}"; - Console.Error.WriteLine($"cdidx: still {phase}{suffix}..."); + ConsoleUi.TryWriteErrorLine($"cdidx: still {phase}{suffix}..."); } }, token); return (cts, task); @@ -685,7 +685,7 @@ void WriteIndexVerboseStatus(string message) if (options.Json) { - Console.Error.WriteLine(message); + ConsoleUi.TryWriteErrorLine(message); return; } @@ -727,7 +727,7 @@ void ReportJsonIndexProgressIfNeeded() || processed % 100 == 0 || Stopwatch.GetElapsedTime(lastJsonProgressAt, now) >= TimeSpan.FromSeconds(5)) { - Console.Error.WriteLine($"cdidx: indexed {processed:N0}/{files.Count:N0} file(s)..."); + ConsoleUi.TryWriteErrorLine($"cdidx: indexed {processed:N0}/{files.Count:N0} file(s)..."); lastJsonProgressAt = now; } } @@ -759,7 +759,7 @@ void StartJsonHeartbeatIfNeeded() currentJsonIndexFile, activeJsonExtractionPhases.OrderBy(static kvp => kvp.Key).Select(static kvp => kvp.Value)); var fileSuffix = string.IsNullOrEmpty(file) ? string.Empty : $": {file}"; - Console.Error.WriteLine($"cdidx: still indexing {processed:N0}/{files.Count:N0} file(s){fileSuffix}..."); + ConsoleUi.TryWriteErrorLine($"cdidx: still indexing {processed:N0}/{files.Count:N0} file(s){fileSuffix}..."); } }, token); } @@ -1108,7 +1108,7 @@ void StopJsonHeartbeat() { PauseIndexSpinnerForConsoleWrite(); ConsoleUi.ClearProgressLine(); - Console.Error.WriteLine(FormatPerFileErrorLine("ERR ", item.FilePath, ex, errorMessage)); + ConsoleUi.TryWriteErrorLine(FormatPerFileErrorLine("ERR ", item.FilePath, ex, errorMessage)); ResumeIndexSpinnerAfterConsoleWrite(); } } diff --git a/src/CodeIndex/Mcp/McpServer.cs b/src/CodeIndex/Mcp/McpServer.cs index 9a6173dc9f..d41d5f9574 100644 --- a/src/CodeIndex/Mcp/McpServer.cs +++ b/src/CodeIndex/Mcp/McpServer.cs @@ -428,7 +428,7 @@ internal async Task RunAsync(IMcpTransport transport, CancellationToken cancella // Use stderr for logging so stdout stays clean for JSON-RPC // stdoutをJSON-RPC用にクリーンに保つため、ログはstderrに出力 - Console.Error.WriteLine($"[cdidx-mcp] Starting MCP server v{_version} (db: {_dbPath}, transport: {transport.Name} @ {transport.Endpoint}, max in-flight: {MaxConcurrency})"); + ConsoleUi.TryWriteErrorLine($"[cdidx-mcp] Starting MCP server v{_version} (db: {_dbPath}, transport: {transport.Name} @ {transport.Endpoint}, max in-flight: {MaxConcurrency})"); if (transport is HttpMcpTransport httpTransport) httpTransport.OutOfBandFrameHandler = ProcessFrame; diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 047c04ba8c..11c4e43ae3 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -25,6 +25,26 @@ public class ConsoleUiTests .Select(field => (OpCode)field.GetValue(null)!) .ToDictionary(opCode => (short)(opCode.Value & 0xff)); + [Fact] + public void TryWriteErrorLine_IgnoresClosedErrorWriter() + { + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + var closedError = new StringWriter(); + closedError.Dispose(); + Console.SetError(closedError); + try + { + ConsoleUi.TryWriteErrorLine("diagnostic"); + } + finally + { + Console.SetError(originalError); + } + } + } + [Fact] public void StartSpinner_BackgroundLoop_DoesNotBlockOnTaskWait() { From 0976854370395eac723dae9bd767adad165f7964 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:19:51 +0900 Subject: [PATCH 5/7] Catch IO failures in diagnostic stderr writes --- src/CodeIndex/Cli/ConsoleUi.cs | 3 +++ tests/CodeIndex.Tests/ConsoleUiTests.cs | 25 +++++++++++++++++++++++++ 2 files changed, 28 insertions(+) diff --git a/src/CodeIndex/Cli/ConsoleUi.cs b/src/CodeIndex/Cli/ConsoleUi.cs index 6da0e09900..0ef11d1395 100644 --- a/src/CodeIndex/Cli/ConsoleUi.cs +++ b/src/CodeIndex/Cli/ConsoleUi.cs @@ -168,6 +168,9 @@ internal static void TryWriteErrorLine(string? value = null) catch (ObjectDisposedException) { } + catch (IOException) + { + } } internal static IDisposable SuppressAnsiForJsonOutput(bool enabled) diff --git a/tests/CodeIndex.Tests/ConsoleUiTests.cs b/tests/CodeIndex.Tests/ConsoleUiTests.cs index 11c4e43ae3..fa39a9f4ed 100644 --- a/tests/CodeIndex.Tests/ConsoleUiTests.cs +++ b/tests/CodeIndex.Tests/ConsoleUiTests.cs @@ -45,6 +45,31 @@ public void TryWriteErrorLine_IgnoresClosedErrorWriter() } } + [Fact] + public void TryWriteErrorLine_IgnoresErrorWriterIoFailures() + { + lock (TestConsoleLock.Gate) + { + var originalError = Console.Error; + Console.SetError(new ThrowingTextWriter()); + try + { + ConsoleUi.TryWriteErrorLine("diagnostic"); + } + finally + { + Console.SetError(originalError); + } + } + } + + private sealed class ThrowingTextWriter : TextWriter + { + public override Encoding Encoding => Encoding.UTF8; + + public override void WriteLine(string? value) => throw new IOException("closed pipe"); + } + [Fact] public void StartSpinner_BackgroundLoop_DoesNotBlockOnTaskWait() { From 222269964b2f3dc327b61d58c957cd2399be65a4 Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 14:24:16 +0900 Subject: [PATCH 6/7] Preserve suggestion store constructor compatibility --- src/CodeIndex/Cli/SuggestionStore.cs | 9 +++++++-- tests/CodeIndex.Tests/SuggestionStoreTests.cs | 6 +++--- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index 2190d99397..0420a20110 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -82,14 +82,19 @@ static SuggestionStore() /// Database filename without extension (optional, defaults to "codeindex"). /// 拡張子なしのデータベースファイル名(任意、デフォルトは "codeindex")。 /// - public SuggestionStore(string cdidxDir, string? dbName = null, TimeProvider? timeProvider = null) + public SuggestionStore(string cdidxDir, string? dbName = null) + : this(cdidxDir, dbName, TimeProvider.System) + { + } + + internal SuggestionStore(string cdidxDir, string? dbName, TimeProvider timeProvider) { // Derive a safe store filename from the DB identity. // DB固有の安全なストアファイル名を導出する。 var safeName = string.IsNullOrWhiteSpace(dbName) ? "codeindex" : dbName; _filePath = Path.Combine(cdidxDir, $"suggestions-{safeName}.json"); _lockPath = Path.Combine(cdidxDir, $"suggestions-{safeName}.lock"); - _timeProvider = timeProvider ?? TimeProvider.System; + _timeProvider = timeProvider; } /// diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index 22f90a0f13..f1dc286f50 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -93,7 +93,7 @@ public void TryAdd_NewSuggestion_ReturnsTrue() public void TryAdd_StampsCreatedAtFromInjectedClockWhenPersisted() { var clock = new ManualTimeProvider(new DateTimeOffset(2030, 2, 3, 4, 5, 6, TimeSpan.Zero)); - var store = new SuggestionStore(_tempDir, timeProvider: clock); + var store = new SuggestionStore(_tempDir, null, clock); var record = MakeRecord("symbol_extraction", "csharp", "Missing record support"); record.CreatedAt = new DateTime(1999, 1, 1, 0, 0, 0, DateTimeKind.Utc); @@ -158,7 +158,7 @@ public void TryAddAndSubmit_FuzzyDuplicate_ReturnsMatchedHashAndScore() public void TryAddAndSubmit_UsesInjectedClockForRetryAndSubmissionTimestamps() { var clock = new ManualTimeProvider(new DateTimeOffset(2031, 3, 4, 5, 6, 7, TimeSpan.Zero)); - var store = new SuggestionStore(_tempDir, timeProvider: clock); + var store = new SuggestionStore(_tempDir, null, clock); var record = MakeRecord("other", null, "submit me"); var result = store.TryAddAndSubmit(record, _ => SuggestionStore.SubmitAttemptResult.Success("https://github.com/Widthdom/CodeIndex/issues/1")); @@ -488,7 +488,7 @@ public void LoadByStatus_ReturnsOnlyMatchingLifecycleState() public void LoadSince_ReturnsSuggestionsAtOrAfterThreshold() { var clock = new ManualTimeProvider(ManualTimeProvider.FixtureUtcNow); - var store = new SuggestionStore(_tempDir, timeProvider: clock); + var store = new SuggestionStore(_tempDir, null, clock); var older = MakeRecord("other", null, "Older suggestion"); var boundary = MakeRecord("other", null, "Boundary suggestion"); var newer = MakeRecord("other", null, "Newer suggestion"); From 16333aa17f93d4d964fdd57e676b00b8af5b6c2b Mon Sep 17 00:00:00 2001 From: Widthdom Date: Sun, 31 May 2026 17:45:28 +0900 Subject: [PATCH 7/7] Fix suggestion pruning clock in CI --- src/CodeIndex/Cli/SuggestionStore.cs | 4 +-- tests/CodeIndex.Tests/SuggestionStoreTests.cs | 30 +++++++++++-------- 2 files changed, 19 insertions(+), 15 deletions(-) diff --git a/src/CodeIndex/Cli/SuggestionStore.cs b/src/CodeIndex/Cli/SuggestionStore.cs index 7881d2403d..b686b2216d 100644 --- a/src/CodeIndex/Cli/SuggestionStore.cs +++ b/src/CodeIndex/Cli/SuggestionStore.cs @@ -781,7 +781,7 @@ private bool PruneUnlocked(List records) { var maxAge = ResolveMaxAge(); var maxCount = ResolveMaxCount(); - var cutoff = DateTime.UtcNow.Subtract(maxAge); + var cutoff = GetUtcNow().Subtract(maxAge); var pruned = records .Where(record => record.CreatedAt != default && record.CreatedAt < cutoff) .ToList(); @@ -807,7 +807,7 @@ private bool PruneUnlocked(List records) ArchivePrunedRecords(pruned); try { - Console.Error.WriteLine($"[cdidx] Pruned {pruned.Count} stale suggestion record(s) to {_archivePath}."); + ConsoleUi.TryWriteErrorLine($"[cdidx] Pruned {pruned.Count} stale suggestion record(s) to {_archivePath}."); } catch (ObjectDisposedException) { diff --git a/tests/CodeIndex.Tests/SuggestionStoreTests.cs b/tests/CodeIndex.Tests/SuggestionStoreTests.cs index 97b411f2c8..d82ddeb5d2 100644 --- a/tests/CodeIndex.Tests/SuggestionStoreTests.cs +++ b/tests/CodeIndex.Tests/SuggestionStoreTests.cs @@ -511,19 +511,19 @@ public void LoadByStatus_ReturnsOnlyMatchingLifecycleState() [Fact] public void LoadSince_ReturnsSuggestionsAtOrAfterThreshold() { - var clock = new ManualTimeProvider(ManualTimeProvider.FixtureUtcNow); + var clock = new ManualTimeProvider(new DateTimeOffset(2031, 5, 1, 9, 0, 0, TimeSpan.Zero)); var store = new SuggestionStore(_tempDir, null, clock); var older = MakeRecord("other", null, "Older suggestion"); var boundary = MakeRecord("other", null, "Boundary suggestion"); var newer = MakeRecord("other", null, "Newer suggestion"); store.TryAdd(older); - clock.SetUtcNow(ManualTimeProvider.FixtureUtcNow.AddDays(1)); + clock.SetUtcNow(new DateTimeOffset(2031, 5, 2, 9, 0, 0, TimeSpan.Zero)); store.TryAdd(boundary); - clock.SetUtcNow(ManualTimeProvider.FixtureUtcNow.AddDays(2)); + clock.SetUtcNow(new DateTimeOffset(2031, 5, 3, 9, 0, 0, TimeSpan.Zero)); store.TryAdd(newer); - var loaded = store.LoadSince(ManualTimeProvider.FixtureUtcNow.AddDays(1)); + var loaded = store.LoadSince(new DateTimeOffset(2031, 5, 2, 9, 0, 0, TimeSpan.Zero)); Assert.Equal(new[] { boundary.Hash, newer.Hash }, loaded.Select(s => s.Hash)); } @@ -785,14 +785,16 @@ public void AtomicWrite_SurvivesAddAfterCorruption() [Fact] public void TryAdd_PrunesStaleRecordsToArchive() { + var clock = new ManualTimeProvider(new DateTimeOffset(2031, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var store = new SuggestionStore(_tempDir, null, clock); var old = MakeRecord("other", null, "Old suggestion"); - old.CreatedAt = DateTime.UtcNow.AddDays(-400); - Assert.True(_store.TryAdd(old)); + Assert.True(store.TryAdd(old)); + clock.SetUtcNow(new DateTimeOffset(2032, 2, 5, 0, 0, 0, TimeSpan.Zero)); var fresh = MakeRecord("other", null, "Fresh suggestion"); - Assert.True(_store.TryAdd(fresh)); + Assert.True(store.TryAdd(fresh)); - var all = _store.LoadAll(); + var all = store.LoadAll(); Assert.Single(all); Assert.Equal("Fresh suggestion", all[0].Description); @@ -826,15 +828,17 @@ public void TryAdd_PrunesOldestRecordsOverConfiguredMaxCount() [Fact] public void TryAdd_DuplicateStillPersistsPrunedRecords() { + var clock = new ManualTimeProvider(new DateTimeOffset(2031, 1, 1, 0, 0, 0, TimeSpan.Zero)); + var store = new SuggestionStore(_tempDir, null, clock); var old = MakeRecord("other", null, "Old suggestion"); - old.CreatedAt = DateTime.UtcNow.AddDays(-400); var duplicate = MakeRecord("other", null, "Duplicate suggestion"); - Assert.True(_store.TryAdd(old)); - Assert.True(_store.TryAdd(duplicate)); + Assert.True(store.TryAdd(old)); + clock.SetUtcNow(new DateTimeOffset(2032, 2, 5, 0, 0, 0, TimeSpan.Zero)); + Assert.True(store.TryAdd(duplicate)); - Assert.False(_store.TryAdd(MakeRecord("other", null, "Duplicate suggestion"))); + Assert.False(store.TryAdd(MakeRecord("other", null, "Duplicate suggestion"))); - var all = _store.LoadAll(); + var all = store.LoadAll(); Assert.Single(all); Assert.Equal("Duplicate suggestion", all[0].Description); var archivePath = Path.Combine(_tempDir, "suggestions-codeindex.archive.jsonl");