V17 / d6d373561692
KanbanSession.cs
Lilith/Core/Kanban/KanbanSession.cs
Unchanged from the preceding versionCS
using System.Text.Json;
using Lilith.Core.Projects;
using Lilith.Core.Scenarios;
using Lilith.Core.Workspace;
using Lilith.Core.Workspace.Extensions;
namespace Lilith.Core.Kanban;
/// <summary>Host-owned sequential scheduler. Saved scenarios own every model interaction.</summary>
public sealed class KanbanSession : IDisposable
{
private readonly ProviderClient _provider;
private readonly string _evidence;
private readonly object _sync = new();
private Task? _worker;
private string? _active;
private volatile bool _pause, _cancel;
private volatile bool _testing;
private readonly CancellationTokenSource _lifetime = new();
internal CancellationToken Lifetime => _lifetime.Token;
internal bool OwnsQualification => _testing;
public KanbanStore Store { get; }
public KanbanDashboard Dashboard { get; }
public bool IsWorking { get { lock (_sync) return _testing || _worker is { IsCompleted: false }; } }
internal Task<string> TestFromDashboard(string id, CancellationToken ct) => _provider.TestKanbanAsync(id, ct);
internal Task<string> PublishFromDashboard(string id, CancellationToken ct) => _provider.PublishKanbanAsync(id, ct);
internal Task<string> ApprovePublicationFromDashboard(string id, CancellationToken ct) => _provider.ApproveKanbanPublicationAsync(id, ct);
internal string ReadPublication(string id)
{
var board = Store.Get(id);
string proposal = board.PublicationProposal ?? throw new InvalidOperationException("Prepare publication first.");
string path = _provider.Workspace!.Options.GetMetadataPath("pending", proposal) + ".json";
path.EnsureNoLinks(); return File.ReadAllText(path);
}
internal byte[] ReadScreenshot(string id,int index)
{
var images = Store.Get(id).Screenshots ?? throw new InvalidOperationException("No captured screenshots.");
if (index < 0 || index >= images.Length) throw new ArgumentException("Unknown screenshot.");
var image = images[index]; string path = Path.GetFullPath(image.File);
string root = Path.GetFullPath(Path.Combine(_provider.Workspace!.Options.RootDirectory,".lilith","website-previews")) + Path.DirectorySeparatorChar;
if (!path.StartsWith(root,StringComparison.OrdinalIgnoreCase)) throw new IOException("Screenshot is outside the owned artifact root.");
path.EnsureNoLinks(); if (new FileInfo(path).Length > 16000000) throw new IOException("Oversized screenshot.");
byte[] bytes = File.ReadAllBytes(path);
if (Convert.ToHexString(System.Security.Cryptography.SHA256.HashData(bytes)) != image.Sha256) throw new IOException("Screenshot changed since capture.");
return bytes;
}
internal KanbanSession(ProviderClient provider)
{
_provider = provider;
var workspace = provider.Workspace ?? throw new InvalidOperationException("Kanban requires a configured workspace.");
_evidence = Path.Combine(workspace.Options.RootDirectory, ".lilith", "kanban");
Store = new(Path.Combine(_evidence, "boards"));
try { Dashboard = new(this); } catch { Store.Dispose(); throw; }
}
internal KanbanBoard Prepare(string name, string kind, string prd, string architecture, int limit)
{
if (limit is < 1 or > 100) throw new ArgumentException("Repair limit must be 1-100.");
if (Store.List().Any(b => b.State is not ("completed" or "failed" or "cancelled"))) throw new InvalidOperationException("Only one active project is allowed. Inspect, resume or cancel the current board.");
string id = Guid.NewGuid().ToString("N");
var generation = new ProjectGeneration(_evidence, _provider.Workspace!);
using var prepared = JsonDocument.Parse(generation.Prepare(name, kind, prd, architecture, ownedId: id));
string contract = prepared.RootElement.GetProperty("contract").GetString()!;
var tasks = ProjectJson.Parse<ProjectTask[]>(prepared.RootElement.GetProperty("tasks").GetRawText());
var cards = tasks.Select(t => new KanbanCard(t.Id, t.Path + (t.Signature.Length == 0 ? "" : " / " + t.Id),
"Implement the owned component following the PRD and shared interface contract. " + t.Requirements,
[], ["Source implements the task and shared contract.", "A separate reviewer inspects the component; runtime acceptance remains pending until project testing."],
"component", Files: [t.Path])).ToList();
cards.Add(new("integration", "Integrate and review the complete project", "Assemble all owned components, build/parse the project and independently review its exact source. Repair real diagnostics within the approved scope.", tasks.Select(t => t.Id).ToArray(), ["Actual build/parse passes.", "Independent structured source review has no remaining findings."], "integration"));
cards.Add(new("test_plan", "Prepare independently reviewed acceptance tests", "Create and freeze executable acceptance tests for the actual private draft. Do not execute it yet.", ["integration"], ["The host validates the retained test plan.", "A separate scenario reviews tests against the PRD and source."], "test_plan"));
cards.Add(new("qualification", "Execute and qualify the project", "After a separate host execution decision, run the complete frozen suite, independent source and coverage reviews, and at most ten total runtime rounds. Retain all failures.", ["test_plan"], ["All retained cases pass on the current source and plan hashes.", "Independent source/coverage review passes."], "qualification"));
if (kind == "website") cards.Add(new("screenshots", "Capture three website previews", "A separate subagent starts the approved snapshot server; another captures Desktop, Tablet and Mobile PNGs and displays them in chat.", ["qualification"], ["Three actual screenshots have valid PNG data and source hashes.", "The owned preview server and browser are closed."], "screenshots"));
cards.Add(new("publication", "Publish the exact qualified project", "Prepare a separate exact workspace publication decision; preserve existing V12 stale-signature and backup protection.", kind == "website" ? ["screenshots"] : ["qualification"], ["Workspace receipt confirms publication of the qualified revision."], "publication"));
bool yolo = _provider.Workspace!.Options.Mode == WorkspaceAccessMode.Yolo;
return Store.Add(new(1, id, name, kind, prd, limit, yolo ? "ready" : "waiting_plan", cards.ToArray(), architecture, contract, tasks, [],
Message: yolo ? "Plan accepted by boot-selected YOLO. Execution permission is still separate." : "Review the complete task plan and approve it before execution.", PlanApproved: yolo));
}
public string Approve(string id, long revision)
{
Store.Update(id, b =>
{
if (b.State != "waiting_plan" || b.Revision != revision) throw new InvalidOperationException("Plan changed or no longer awaits approval.");
return b with { PlanApproved = true, State = "ready", Message = "Exact plan approved by user." };
});
return "Plan approved. " + Resume(id);
}
public string Pause(string id)
{
lock (_sync)
{
var board = Store.Get(id);
if (board.State is "completed" or "failed" or "cancelled") return board.Message;
_pause = true;
Store.Update(id, b => b with { State = IsWorking ? "pausing" : "paused", Message = "Pause requested; the current agent may finish. No next agent will start." });
}
return "Pause requested. Return to chat when state is paused.";
}
public async Task<string> CancelAsync(string id, CancellationToken ct = default)
{
string? publication;
lock (_sync)
{
var board = Store.Get(id);
if (board.State is "completed" or "failed" or "cancelled") return board.Message;
publication = board.PublicationProposal;
_cancel = true;
Store.Update(id, b => b with { State = IsWorking ? "cancelling" : "cancelled", FinishedAt = IsWorking ? null : DateTimeOffset.UtcNow,
Message = "Cancellation requested. Private source and evidence are retained; no publication is authorized." });
}
if (publication is not null) await _provider.Workspace!.ExecuteAsync(new("reject", BinId: publication), ct);
return "Cancellation requested.";
}
public string Resume(string id)
{
lock (_sync)
{
var board = Store.Get(id);
if (!board.PlanApproved) throw new InvalidOperationException("User plan approval required.");
if (board.State is "completed" or "failed" or "cancelled") throw new InvalidOperationException("Run is terminal. Inspect its report; do not silently restart its budgets.");
if (IsWorking) throw new InvalidOperationException("A Kanban worker is already active.");
_active = id; _pause = false; _cancel = false;
_worker = Task.Run(() => _provider.ExecuteKanbanAsync(id));
}
return "Kanban running. Board: " + Dashboard.Url;
}
internal void StartReady()
{
if (IsWorking) return;
var ready = Store.List().FirstOrDefault(b => b.State == "ready" && b.PlanApproved);
if (ready is not null) Resume(ready.Id);
}
internal void Boundary()
{
if (_active is null) return;
if (_cancel) throw new KanbanYieldException(true);
if (_pause) throw new KanbanYieldException(false);
}
internal void BeginQualification(string id)
{
_active = id; _testing = true; _pause = false; _cancel = false;
Change("qualification", c => c with { Column = KanbanColumn.Testing });
Store.Update(id, b => b with { State = "testing", Message = "User approved executing this project and its bounded repair revisions." });
}
internal void FinishQualification(string id, bool passed, string result)
{
Change("qualification", c => c with { Column = passed ? KanbanColumn.Done : KanbanColumn.Blocked, Evidence = result });
Store.Update(id, b => b with { State = passed ? "waiting_publication" : "failed", FinishedAt = passed ? null : DateTimeOffset.UtcNow,
Cards = b.Cards.Select(c => c.Id == "publication" && passed ? c with { Column = KanbanColumn.Ready }
: passed && c.Role is "component" or "integration" or "runtime_repair" ? c with { Column = KanbanColumn.Done }
: !passed && c.Role == "runtime_repair" ? c with { Column = KanbanColumn.Blocked } : c).ToArray(), Message = result });
_active = null; _testing = false;
}
internal void SetScreenshots(string id, WebsiteScreenshot[] images)
{
Store.Update(id,b => b with { Screenshots = images, Cards = b.Cards.Select(c => c.Id == "screenshots" ? c with { Column = KanbanColumn.Done, Evidence = ProjectJson.Serialize(images) } : c).ToArray() });
}
internal void YieldQualification(string id, bool cancel)
{
var draftId = Store.Get(id).DraftId;
bool qualified = draftId is not null && new ProjectDraftStore(_provider.Workspace!).Load(draftId).State == "ready_to_publish";
Store.Update(id, b => b with { State = cancel ? "cancelled" : "paused", FinishedAt = cancel ? DateTimeOffset.UtcNow : null,
Cards = b.Cards.Select(c => c.Id == "qualification" ? c with { Column = qualified ? KanbanColumn.Done : KanbanColumn.Ready }
: c.Id == "screenshots" && c.Column == KanbanColumn.Doing ? c with { Column = KanbanColumn.Ready } : c).ToArray(),
Message = cancel ? "Cancelled; runtime evidence retained; unpublished." : "Qualification paused; explicit resume and fresh execution decision required." });
_active = null; _testing = false;
}
internal void RuntimeRound(ProjectRuntimeReport report, int round)
{
if (!_testing || _active is null) return;
var board = Store.Get(_active);
bool repairedFailed = !report.Passed && round > 1;
Store.Update(board.Id, b => b with { Cards = b.Cards.Select(c => c.Id == "qualification"
? c with { FailedRepairs = c.FailedRepairs + (repairedFailed ? 1 : 0), Evidence = ProjectJson.Serialize(report) }
: !report.Passed && c.Role is "component" or "integration" ? c with { Column = KanbanColumn.Testing } : c).ToArray() });
if (!report.Passed)
{
var current = Store.Get(board.Id);
Store.Update(board.Id, b => b with { Cards = b.Cards.Append(new KanbanCard("runtime_repair_" + round, "Repair runtime findings / round " + round,
string.Join("\n", report.Errors.Concat(report.Cases.Where(c => c.Status != "passed").Select(c => c.Id + ": " + c.Actual))), [],
["Rerun all frozen checks and independent review; do not weaken failures."], "runtime_repair", KanbanColumn.Review, Parent: "qualification", Evidence: report.Evidence)).ToArray() });
if (current.Cards.Single(c => c.Id == "qualification").FailedRepairs >= current.RepairLimit)
throw new InvalidOperationException("Per-task failed repair limit reached; project remains unpublished.");
}
}
internal void Publication(string id, WorkspaceResult result)
{
bool pending = result.Status == "needs_approval", published = result.Status == "completed";
Store.Update(id, b => b with { State = pending ? "waiting_publication" : published ? "completed" : "failed", PublicationProposal = pending ? result.ProposalId : null,
FinishedAt = pending ? null : DateTimeOffset.UtcNow, Message = result.ToReply(),
Cards = b.Cards.Select(c => c.Id == "publication" ? c with { Column = pending ? KanbanColumn.Ready : published ? KanbanColumn.Done : KanbanColumn.Blocked, Evidence = result.ToReply() } : c).ToArray() });
}
internal void Observe(ScenarioProgress progress)
{
if (_active is null || progress.Kind != "Started") return;
var board = Store.Get(_active); var task = _testing ? board.Cards.FirstOrDefault(c => c.Id == "screenshots" && c.Column == KanbanColumn.Doing) ?? board.Cards.Single(c => c.Id == "qualification") : board.Cards.FirstOrDefault(c => c.Role != "repair" && c.Column is KanbanColumn.Doing or KanbanColumn.Review or KanbanColumn.Testing);
if (task is null) return;
Change(task.Id, c => c with { Agents = (c.Agents ?? []).Append(progress.RunId).Distinct().TakeLast(200).ToArray() });
}
private void Change(string card, Func<KanbanCard, KanbanCard> update) => Store.Update(_active!, b => b with { Cards = b.Cards.Select(c => c.Id == card ? update(c) : c).ToArray() });
private ProjectGeneration Reconstruct(KanbanBoard b)
{
var generation = new ProjectGeneration(_evidence, _provider.Workspace!);
generation.Prepare(b.Name, b.Kind, b.Prd, b.Architecture, ownedId: b.Id);
foreach (var pair in b.Sources) generation.Store(b.Id, pair.Key, pair.Value);
return generation;
}
private async Task<string> Scenario(string name, Dictionary<string, string> inputs, CancellationToken ct)
{
Boundary(); return await ProjectQualification.Scenario(_provider, name, inputs, ct);
}
internal async Task RunAsync(string id, CancellationToken ct = default)
{
_active = id;
try
{
Store.Update(id, b => b with { State = "running", Message = "Executing one scenario agent at a time." });
while (true)
{
Boundary(); var board = Store.Get(id);
foreach (var c in board.Cards.Where(c => c.Column == KanbanColumn.Backlog && c.Dependencies.All(d => board.Cards.Single(t => t.Id == d).Column == KanbanColumn.Done)))
Change(c.Id, t => t with { Column = KanbanColumn.Ready });
board = Store.Get(id);
var card = board.Cards.FirstOrDefault(c => c.Column == KanbanColumn.Ready);
if (card is null)
{
bool complete = board.Cards.All(c => c.Column == KanbanColumn.Done);
Store.Update(id, b => b with { State = complete ? "completed" : "failed", FinishedAt = DateTimeOffset.UtcNow,
Message = complete ? "Qualified project published. See task evidence for coverage limits." : "Unresolved or blocked work remains. Nothing is qualified for publication." }); return;
}
if (card.Role is "qualification" or "screenshots" or "publication")
{
Store.Update(id, b => b with { State = card.Role != "publication" ? "waiting_test" : "waiting_publication", Message = card.Role != "publication"
? "Explicit execution permission required for this project: /kanban-test " + id
: "Qualified draft ready. Request separate exact publication: /kanban-publish " + id }); return;
}
Change(card.Id, c => c with { Column = KanbanColumn.Doing });
try
{
switch (card.Role)
{
case "component": await Component(board, card, ct); break;
case "integration": await Integrate(board, ct); break;
case "test_plan": await PlanTests(board, ct); break;
default: throw new InvalidOperationException("Unknown host task role.");
}
Change(card.Id, c => c with { Column = KanbanColumn.Done });
Store.Update(id, b => b with { Cards = b.Cards.Select(c => c.Parent == card.Id && c.Role == "repair" ? c with { Column = KanbanColumn.Done, Evidence = c.Evidence + "\nParent task subsequently passed its declared checks." } : c).ToArray() });
}
catch (KanbanYieldException) { Change(card.Id, c => c with { Column = KanbanColumn.Ready }); throw; }
catch (Exception ex) when (!ct.IsCancellationRequested)
{
FailTask(card.Id, ex.Message);
}
}
}
catch (KanbanYieldException ex)
{
Store.Update(id, b => b with { State = ex.Cancel ? "cancelled" : "paused", FinishedAt = ex.Cancel ? DateTimeOffset.UtcNow : null, Message = ex.Cancel ? "Cancelled; private draft and evidence retained." : "Paused before the next agent. Chat is available." });
}
catch (Exception ex)
{ Store.Update(id, b => b with { State = "interrupted", Message = "Stopped without publication: " + ex.Message }); }
finally { _active = null; }
}
private void FailTask(string task, string error)
{
var board = Store.Get(_active!); var old = board.Cards.Single(c => c.Id == task);
int count = old.FailedRepairs + (old.Attempts == 0 ? 0 : 1);
string repair = task + "_repair_" + (old.Attempts + 1);
Store.Update(board.Id, b => b with { Cards = b.Cards.Select(c => c.Id == task ? c with { FailedRepairs = count, Attempts = c.Attempts + 1, Column = count >= b.RepairLimit ? KanbanColumn.Blocked : KanbanColumn.Ready, Artifact = c.Role == "component" ? null : c.Artifact, Evidence = error } : c).Append(
new KanbanCard(repair, "Repair " + old.Title, error, [], ["Resolve the retained diagnostic without weakening acceptance checks."], "repair", KanbanColumn.Blocked, Parent: task, Evidence: error)).ToArray(),
Message = count >= b.RepairLimit ? "Task repair limit reached: " + task : "Task failed; retained diagnostic requires repair: " + task });
}
private async Task Component(KanbanBoard b, KanbanCard card, CancellationToken ct)
{
if (b.Sources.ContainsKey(card.Id)) return; // Reviewed source was committed before interruption.
var task = b.Components.Single(t => t.Id == card.Id);
var input = new Dictionary<string, string> { ["kind"] = b.Kind, ["prd"] = b.Prd, ["contract"] = b.Contract, ["task"] = ProjectJson.Serialize(task) };
if (card.Evidence.Length > 0) input["prd"] += "\nActual previous attempt failed; correct this diagnostic within the original scope: " + card.Evidence;
string draft = card.Artifact ?? await Scenario("project_component", input, ct);
Change(card.Id, c => c with { Artifact = draft, Column = KanbanColumn.Review });
input["draft"] = draft;
string reviewed = await Scenario("project_review_component", input, ct);
var generation = Reconstruct(Store.Get(b.Id)); generation.Store(b.Id, card.Id, reviewed);
Store.Update(b.Id, board => board with { Sources = new(board.Sources) { [card.Id] = reviewed }, Cards = board.Cards.Select(c => c.Id == card.Id ? c with { Evidence = "Separate component reviewer returned this source; runtime testing is still pending.", Artifact = reviewed } : c).ToArray() });
}
private async Task Integrate(KanbanBoard b, CancellationToken ct)
{
if (b.DraftId is not null) { _ = new ProjectDraftStore(_provider.Workspace!).Load(b.DraftId); return; }
var generation = Reconstruct(b); generation.Assemble(b.Id);
if (b.Bundle is not null) await generation.ValidateAsync(b.Id, b.Bundle, ct);
string bundle = generation.CurrentDisplay;
string diagnostic = Store.Get(b.Id).Cards.Single(c => c.Id == "integration").Evidence;
Boundary();
string integrated = await Scenario("project_integrate", new() { ["kind"] = b.Kind, ["prd"] = b.Prd, ["architecture"] = b.Architecture, ["bundle"] = bundle, ["diagnostics"] = diagnostic.Length == 0 ? "Initial integration; no runtime execution has occurred." : diagnostic }, ct);
string validated = await generation.ValidateAsync(b.Id, integrated, ct);
bundle = generation.CurrentDisplay;
Store.Update(b.Id, board => board with { Bundle = ProjectJson.Serialize(generation.CurrentBundle) });
Change("integration", c => c with { Column = KanbanColumn.Review, Evidence = validated });
string review = await Scenario("project_review_project", new() { ["kind"] = b.Kind, ["prd"] = b.Prd, ["bundle"] = bundle, ["diagnostics"] = validated }, ct);
string result = generation.Review(b.Id, review);
using var parsed = JsonDocument.Parse(result);
if (parsed.RootElement.GetProperty("passed").GetBoolean())
{
string staged = generation.Stage(b.Id); using var receipt = JsonDocument.Parse(staged);
Store.Update(b.Id, board => board with { DraftId = receipt.RootElement.GetProperty("id").GetString() });
Change("integration", c => c with { Evidence = result }); return;
}
throw new InvalidOperationException(result);
}
private async Task PlanTests(KanbanBoard b, CancellationToken ct)
{
if (b.DraftId is null) throw new InvalidOperationException("No staged project draft.");
var retained = new ProjectDraftStore(_provider.Workspace!).Load(b.DraftId);
if (retained.Tests.Length > 0 && retained.State == "awaiting_test_approval") return;
string result = await Scenario("project_plan_tests", new() { ["id"] = b.DraftId }, ct);
var draft = new ProjectDraftStore(_provider.Workspace!).Load(b.DraftId);
if (draft.Tests.Length == 0 || draft.State != "awaiting_test_approval") throw new InvalidOperationException(result);
Change("test_plan", c => c with { Evidence = result });
}
public async Task StopAsync()
{
_cancel = true; _lifetime.Cancel();
Task? work; lock (_sync) work = _worker;
if (work is not null) await work;
while (_testing) await Task.Delay(25);
}
public void Dispose() { _cancel = true; _lifetime.Cancel(); Dashboard.Dispose(); Store.Dispose(); _lifetime.Dispose(); }
}
internal sealed class KanbanYieldException(bool cancel) : Exception("Kanban yielded at an agent boundary.")
{ internal bool Cancel { get; } = cancel; }