Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
21 commits
Select commit Hold shift + click to select a range
b2c685c
feat: add searchable library and safe release upgrades
mahoshojoHCG Aug 29, 2026
f6fda4e
fix: make release upgrades atomic and recoverable
mahoshojoHCG Aug 30, 2026
a0313e1
Merge main and harden library release upgrades
mahoshojoHCG Aug 31, 2026
d918baf
Merge main and finalize release upgrade hardening
mahoshojoHCG Aug 31, 2026
330b8c2
Merge remote-tracking branch 'origin/main' into codex/issue-12
mahoshojoHCG Aug 31, 2026
81903d5
Fix staged release activation and search cursors
mahoshojoHCG Aug 31, 2026
0dd1a38
Preserve staged candidate mappings on rollback
mahoshojoHCG Aug 31, 2026
95b6506
Fix legacy release migration and watch cursors
mahoshojoHCG Aug 31, 2026
52fbe5e
Scope legacy release ranking to episode media
mahoshojoHCG Aug 31, 2026
07d669e
Identify canonical episode mappings by path shape
mahoshojoHCG Aug 31, 2026
826b833
Fix successor promotion and upgrade cleanup
mahoshojoHCG Aug 31, 2026
ab6432b
Recover claimed upgrades and track retired releases
mahoshojoHCG Aug 31, 2026
5f3f4f2
Harden download submission and cancellation recovery
mahoshojoHCG Aug 31, 2026
87cc75f
Fence stale download submission recovery
mahoshojoHCG Aug 31, 2026
4ee01b0
Remove download saga regression assertions
mahoshojoHCG Aug 31, 2026
e3a60fb
Route release upgrade incident retries
mahoshojoHCG Aug 31, 2026
b282862
Resolve superseded upgrade incidents
mahoshojoHCG Aug 31, 2026
b28a097
Honor qBittorrent submission failures
mahoshojoHCG Aug 31, 2026
d6ad03d
Reconcile ordinary download submissions
mahoshojoHCG Aug 31, 2026
337763c
Exclude unscored releases from automatic upgrades
mahoshojoHCG Aug 31, 2026
eea53b1
Filter settled release upgrade candidates
mahoshojoHCG Aug 31, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
121 changes: 111 additions & 10 deletions Plugins/SecondDimensionWatcherReDive.Chat/Tools/ManageDownloadsTool.cs
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
using System.Diagnostics;
using SecondDimensionWatcherReDive.AI.Models;
using SecondDimensionWatcherReDive.Framework.AI;
using SecondDimensionWatcherReDive.Framework.Attributes;
Expand All @@ -14,6 +15,12 @@ internal sealed partial class ManageDownloadsTool(
IFileMappingRepository fileMappingRepository,
IFileDownloadClientProvider fileDownloadClientProvider) : ITool
{
private static readonly TimeSpan DownloadSubmissionLeaseDuration = TimeSpan.FromMinutes(3);
private static readonly TimeSpan DownloadSubmissionRemoteBudget = TimeSpan.FromSeconds(90);
private static readonly TimeSpan DownloadCancellationLeaseDuration = TimeSpan.FromMinutes(3);
private static readonly TimeSpan DownloadCancellationRemoteBudget = TimeSpan.FromSeconds(90);
private static readonly TimeSpan DownloadLeaseSafetyMargin = TimeSpan.FromSeconds(1);

private async Task<IToolResult> ExecuteCoreAsync(
ManageDownloadsParams param, CancellationToken cancellationToken)
{
Expand Down Expand Up @@ -45,32 +52,71 @@ private async Task<IToolResult> StartDownloadAsync(
return new ToolFailureResult("Download already tracked");

var downloadAttemptId = Guid.NewGuid();
var submissionLeaseId = Guid.NewGuid();
var leaseRequestStartedAt = Stopwatch.GetTimestamp();
var submissionAttempted = false;
try
{
if (!await animationInfoRepository.TryStartDownloadAsync(
var submissionLease = await animationInfoRepository.TryStartDownloadAsync(
info.Id,
downloadAttemptId,
submissionLeaseId,
DownloadSubmissionLeaseDuration,
DateTimeOffset.Now,
queuedDisposition: null,
cancellationToken))
cancellationToken);
if (submissionLease is null)
return new ToolFailureResult("Download already tracked");

var remainingRemoteBudget = DownloadSubmissionRemoteBudget -
Stopwatch.GetElapsedTime(leaseRequestStartedAt);
if (remainingRemoteBudget <= TimeSpan.Zero)
{
await CompensateFailedStartAsync(
info,
client,
downloadAttemptId,
submissionLeaseId,
remoteMayHaveAccepted: false);
return new ToolFailureResult("Download submission lease expired");
}

using var submissionCancellation = CancellationTokenSource.CreateLinkedTokenSource(
cancellationToken);
submissionCancellation.CancelAfter(remainingRemoteBudget);
submissionCancellation.Token.ThrowIfCancellationRequested();
submissionAttempted = true;
if (!await client.SubmitDownloadTaskAsync(
info.Id,
info.DownloadUrl,
info.CachedDownloadData,
info.AdditionalDownloadInfo,
cancellationToken))
submissionCancellation.Token))
{
await CompensateFailedStartAsync(
info,
client,
downloadAttemptId,
submissionLeaseId,
remoteMayHaveAccepted: false);
return new ToolFailureResult("Download client rejected the task");
}

using var markCancellation = CreateDownloadSagaTokenSource();
if (!await animationInfoRepository.TryMarkDownloadSubmittedAsync(
info.Id,
downloadAttemptId,
submissionLeaseId,
markCancellation.Token))
{
await CompensateFailedStartAsync(
info,
client,
downloadAttemptId,
submissionLeaseId,
remoteMayHaveAccepted: true);
return new ToolFailureResult("Download state changed during submission");
}
}
catch
{
Expand All @@ -80,6 +126,7 @@ await CompensateFailedStartAsync(
info,
client,
downloadAttemptId,
submissionLeaseId,
submissionAttempted);
}
catch
Expand Down Expand Up @@ -112,35 +159,57 @@ private async Task<IToolResult> CancelDownloadAsync(
AnimationInfo info, IFileDownloadClient client, bool removeFile, CancellationToken cancellationToken)
{
var cancellationAttemptId = info.DownloadCancellationId ?? Guid.NewGuid();
var cancellationLeaseId = Guid.NewGuid();
var leaseRequestStartedAt = Stopwatch.GetTimestamp();
DownloadCancellationLease? cancellationLease;
cancellationToken.ThrowIfCancellationRequested();
using (var beginCancellation = CreateDownloadSagaTokenSource())
{
if (!await animationInfoRepository.TryBeginCancelDownloadAsync(
cancellationLease = await animationInfoRepository.TryBeginCancelDownloadAsync(
info.Id,
info.DownloadAttemptId,
cancellationAttemptId,
beginCancellation.Token))
cancellationLeaseId,
DownloadCancellationLeaseDuration,
removeFile,
requireUnfinished: false,
SubscriptionAutomationDisposition.DownloadCancelled,
beginCancellation.Token);
if (cancellationLease is null)
return new ToolFailureResult("Download state changed before cancellation");
}

var remainingRemoteBudget = DownloadCancellationRemoteBudget -
Stopwatch.GetElapsedTime(leaseRequestStartedAt);
if (remainingRemoteBudget <= TimeSpan.Zero)
return new ToolFailureResult("Download cancellation lease expired");
using var remoteCancellation = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
remoteCancellation.CancelAfter(remainingRemoteBudget);
remoteCancellation.Token.ThrowIfCancellationRequested();
var result = await client.CancelDownloadTaskAsync(
info.Id,
info.DownloadUrl,
info.CachedDownloadData,
info.AdditionalDownloadInfo,
removeFile,
cancellationToken);
cancellationLease.RemoveFile,
remoteCancellation.Token);

if (!result.IsSuccess)
{
return new ToolSuccessResult<bool>(false);
}

using var finalizeCancellation = CreateDownloadSagaTokenSource();
using var finalizeCancellation = CreateLeaseBoundSagaTokenSource(
leaseRequestStartedAt,
DownloadCancellationLeaseDuration);
if (finalizeCancellation is null)
return new ToolFailureResult("Download cancellation lease expired");
var cancelled = await fileMappingRepository.TryFinalizeDownloadCancellationAsync(
info.Id,
info.DownloadAttemptId,
cancellationAttemptId,
cancellationLease.Id,
SubscriptionAutomationDisposition.DownloadCancelled,
finalizeCancellation.Token);
if (!cancelled)
return new ToolFailureResult("Download state changed during cancellation");
Expand All @@ -151,19 +220,35 @@ private async Task CompensateFailedStartAsync(
AnimationInfo info,
IFileDownloadClient client,
Guid downloadAttemptId,
Guid submissionLeaseId,
bool remoteMayHaveAccepted)
{
using var cleanup = CreateDownloadSagaTokenSource();
var cancellationAttemptId = Guid.NewGuid();
var cancellationLease = await animationInfoRepository.TryBeginCancelDownloadAsync(
info.Id,
downloadAttemptId,
cancellationAttemptId,
submissionLeaseId,
DownloadCancellationLeaseDuration,
removeFile: false,
requireUnfinished: true,
terminalDisposition: null,
cleanup.Token);
if (cancellationLease is null)
return;

if (remoteMayHaveAccepted)
{
try
{
cleanup.Token.ThrowIfCancellationRequested();
var remoteCancellation = await client.CancelDownloadTaskAsync(
info.Id,
info.DownloadUrl,
info.CachedDownloadData,
info.AdditionalDownloadInfo,
removeFile: false,
cancellationLease.RemoveFile,
cleanup.Token);
if (!remoteCancellation.IsSuccess)
{
Expand All @@ -178,9 +263,12 @@ private async Task CompensateFailedStartAsync(
}
}

await animationInfoRepository.TryCancelDownloadAsync(
cleanup.Token.ThrowIfCancellationRequested();
await fileMappingRepository.TryFinalizeDownloadCancellationAsync(
info.Id,
downloadAttemptId,
cancellationAttemptId,
cancellationLease.Id,
terminalDisposition: null,
cleanup.Token);
}
Expand All @@ -207,6 +295,19 @@ await client.SubmitQueryDownloadProgressAsync(

private static CancellationTokenSource CreateDownloadSagaTokenSource() =>
new(TimeSpan.FromSeconds(10));

private static CancellationTokenSource? CreateLeaseBoundSagaTokenSource(
long leaseRequestStartedAt,
TimeSpan leaseDuration)
{
var remaining = leaseDuration -
Stopwatch.GetElapsedTime(leaseRequestStartedAt) -
DownloadLeaseSafetyMargin;
if (remaining <= TimeSpan.Zero)
return null;
return new CancellationTokenSource(
remaining < TimeSpan.FromSeconds(10) ? remaining : TimeSpan.FromSeconds(10));
}
}

internal enum ManageDownloadsAction
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,33 @@ public async Task<string> GetSeasonEpisodesAsync(int tmdbId, int seasonNumber, C
}
}

public async Task<int?> GetExpectedEpisodeCountAsync(
int tmdbId,
int seasonNumber,
CancellationToken cancellationToken)
{
var tmdbClient = GetClient();
if (tmdbClient is null || seasonNumber <= 0) return null;

try
{
var show = await tmdbClient.GetTvShowAsync(tmdbId, cancellationToken: cancellationToken);
var count = show?.Seasons?
.FirstOrDefault(season => season.SeasonNumber == seasonNumber)
?.EpisodeCount;
return count is > 0 ? count : null;
}
catch (OperationCanceledException) when (cancellationToken.IsCancellationRequested)
{
throw;
}
catch (Exception ex)
{
LogGetSeasonsFailed(_logger, ex, tmdbId);
return null;
}
}

/// <summary>
/// Fetches localized name, original name, and overview for a TV show from TMDB,
/// using the server's current culture as the language.
Expand Down
Loading