agent: unify listening and dialing with directed mutual measurements

Replace Probe with Agent across node runtime, Fleet contracts, backend state, API and storage. Report incoming sessions and outgoing link health through each Agent uplink, and retain directed link controls and retry.

Update protocol fixtures and end-to-end coverage for the unified model; accept requested cancellation during the Fleet timeout test cleanup.
This commit is contained in:
justme committed 2026-10-21 11:55:36 +08:00
1 parent d5ebf29e57
commit ad5efc62ff
206 files changed
+4202 -4964

No files matched your search

@@ -127,7 +127,7 @@ public sealed class TargetRunners
return IPAddress.TryParse(target, out address!);
}
/// <summary>目标任务的行没有 Probe 一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。</summary>
/// <summary>目标任务的行没有拨号方一侧可以合并:积木交出的就是完整的行,Agent 自己盖章(目标、时钟锚点)并放进发件箱。</summary>
private sealed class LocalSink : IFragmentSink
{
private readonly TargetRunners _owner;
@@ -156,7 +156,7 @@ public sealed class TargetRunners
Port = port,
StartUtcMs = startUtcMs,
DurationMs = Math.Clamp(durationMs, 1, ChunkFinalizer.MaxDurationMs),
Clock = new ChunkClock { AgentMonoUs = anchor.AgentMonoUs, UtcMs = anchor.UtcMs, ProbeOffsetUs = 0 },
Clock = new ChunkClock { ListenerMonoUs = anchor.ListenerMonoUs, UtcMs = anchor.UtcMs, DialerOffsetUs = 0 },
};
_owner._chunks.Emit(new RawChunk(header, streams));
}
@@ -9,9 +9,9 @@ namespace MoShiro.Agent;
/// <summary>
/// 期望任务集(<c>task_set</c>)的调和器:每个任务先经过 <see cref="BudgetGuard"/>(参数校验 + 本地硬上限)。
/// 通过的线路任务<b>为每个 Probe 会话各登记一组流</b>(流编号和监听方积木都属于会话,互不干扰),并汇总成 <c>line_task_set</c> 下发给这个 Probe;
/// 通过的目标任务在本地运行,与 Probe 无关。被拒绝的任务带着原因(<c>rejected[]</c>)回报给 Backend,其余任务照常运行。
/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个 Probe 会话计算,会话总数由 <c>PeerListenerOptions.MaxSessions</c> 限制。
/// 通过的线路任务<b>为每个拨号方会话各登记一组流</b>(流编号和监听方积木都属于会话,互不干扰),并汇总成 <c>line_task_set</c> 下发给这个拨号方;
/// 通过的目标任务在本地运行,与拨号方无关。被拒绝的任务带着原因(<c>rejected[]</c>)回报给 Backend,其余任务照常运行。
/// 每次调用都应用完整的快照,所以是幂等的。预算(码率、包速率)按单个拨号方会话计算,会话总数由 <c>PeerListenerOptions.MaxSessions</c> 限制。
/// </summary>
public sealed class TaskManager
{
@@ -27,15 +27,17 @@ public sealed class TaskManager
private readonly Func<IReadOnlyList<int>> _quicPorts;
private readonly Func<IReadOnlyList<int>> _tcpPorts;
private readonly TrustAnchor _trust;
private readonly Dictionary<ProbeSession, Dictionary<string, LineEntry>> _sessionLines = [];
private readonly bool _acceptLineTasks;
private readonly Dictionary<DialerSession, Dictionary<string, LineEntry>> _sessionLines = [];
private volatile Dictionary<string, byte[]> _peers = new(StringComparer.Ordinal);
private Dictionary<string, (TaskSpec Task, TaskDecision Decision)> _lineSpecs = [];
private long _version;
private BudgetGuard? _guard;
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null)
public TaskManager(Func<BudgetGuard> guardFactory, StreamRegistry streams, BlockEnv env, TargetRunners targets, Func<IReadOnlyList<int>> quicPorts, Func<IReadOnlyList<int>> tcpPorts, TrustAnchor? trust = null, bool acceptLineTasks = true)
{
_trust = trust ?? TrustAnchor.None;
_acceptLineTasks = acceptLineTasks;
_guardFactory = guardFactory;
_streams = streams;
_env = env;
@@ -46,43 +48,43 @@ public sealed class TaskManager
public BudgetGuard Guard => _guard ??= _guardFactory();
/// <summary>某个 Probe 的线路任务集变化(版本递增):由 <see cref="PeerHub"/> 推送给这个 Probe。</summary>
public Action<ProbeSession, LineTaskSet>? OnLineTasksChanged { get; set; }
/// <summary>某个拨号方的线路任务集变化(版本递增):由 <see cref="PeerHub"/> 推送给这个拨号方。</summary>
public Action<DialerSession, LineTaskSet>? OnLineTasksChanged { get; set; }
/// <summary>允许连接的 Probe 变了(期望状态里的 peers):已经不在名单里的会话由 <see cref="PeerHub"/> 关闭。</summary>
/// <summary>允许连接的拨号方变了(期望状态里的 peers):已经不在名单里的会话由 <see cref="PeerHub"/> 关闭。</summary>
public Action<IReadOnlySet<string>>? OnPeersChanged { get; set; }
/// <summary>点对点握手时查找 Probe 的公钥(SPKI)。</summary>
/// <summary>点对点握手时查找拨号方的公钥(SPKI)。</summary>
public byte[]? LookupPeerKey(string peerId) => _peers.TryGetValue(peerId, out byte[]? key) ? key : null;
/// <summary>名册里全部 Probe 的公钥:敲门关卡用它们派生配对密钥。</summary>
/// <summary>名册里全部拨号方的公钥:敲门关卡用它们派生配对密钥。</summary>
public IReadOnlyList<byte[]> PeerKeys() => [.. _peers.Values];
/// <summary>一个新的 Probe 会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
public LineTaskSet OpenSession(ProbeSession probe)
/// <summary>一个新的拨号方会话开始:为它登记全部线路任务的流并启动监听方积木,返回要下发给它的线路任务集。</summary>
public LineTaskSet OpenSession(DialerSession dialer)
{
lock (_gate)
{
var entries = new Dictionary<string, LineEntry>();
foreach ((string id, (TaskSpec task, TaskDecision decision)) in _lineSpecs)
{
LineEntry entry = BuildLineEntry(task, decision, probe);
LineEntry entry = BuildLineEntry(task, decision, dialer);
Start(entry);
entries[id] = entry;
}
_sessionLines[probe] = entries;
_sessionLines[dialer] = entries;
return SetFor(entries);
}
}
/// <summary>Probe 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。</summary>
public async Task CloseSessionAsync(ProbeSession probe)
/// <summary>拨号方 会话结束:释放它的所有流和积木(记录随之丢弃,下一个会话从零开始)。</summary>
public async Task CloseSessionAsync(DialerSession dialer)
{
List<LineEntry> entries;
lock (_gate)
{
if (!_sessionLines.Remove(probe, out Dictionary<string, LineEntry>? lines))
if (!_sessionLines.Remove(dialer, out Dictionary<string, LineEntry>? lines))
{
return;
}
@@ -108,11 +110,11 @@ public sealed class TaskManager
{
try
{
peers[peer.ProbeId.Value] = Convert.FromBase64String(peer.PublicKey);
peers[peer.NodeId.Value] = Convert.FromBase64String(peer.PublicKey);
}
catch (FormatException)
{
result.Rejected.Add(new RejectedItem { Id = peer.ProbeId.Value, Reason = RejectReasons.InvalidParams });
result.Rejected.Add(new RejectedItem { Id = peer.NodeId.Value, Reason = RejectReasons.InvalidParams });
}
}
@@ -131,7 +133,7 @@ public sealed class TaskManager
continue;
}
bool shapeOk = task.Scope == "target" ? s_targetKinds.Contains(task.Kind) : s_lineKinds.Contains(task.Kind);
bool shapeOk = task.Scope == "target" ? s_targetKinds.Contains(task.Kind) : _acceptLineTasks && s_lineKinds.Contains(task.Kind);
TaskDecision decision = shapeOk ? guard.Check(task, ref usedMbps, ref usedPps) : TaskDecision.Reject(RejectReasons.Unsupported);
if (!decision.Accepted)
{
@@ -153,18 +155,18 @@ public sealed class TaskManager
// 参数没有变化的任务沿用原来的流和积木,数据面上的会话(重放窗口、记录)不被打断
var retired = new List<LineEntry>();
var changes = new List<(ProbeSession Probe, LineTaskSet Set)>();
var changes = new List<(DialerSession Dialer, LineTaskSet Set)>();
lock (_gate)
{
_lineSpecs = line;
_version = version;
foreach ((ProbeSession probe, Dictionary<string, LineEntry> current) in _sessionLines)
foreach ((DialerSession dialer, Dictionary<string, LineEntry> current) in _sessionLines)
{
var next = new Dictionary<string, LineEntry>();
bool lineTasksChanged = current.Count != line.Count;
foreach ((string id, (TaskSpec task, TaskDecision decision)) in line)
{
LineEntry candidate = BuildLineEntry(task, decision, probe);
LineEntry candidate = BuildLineEntry(task, decision, dialer);
if (current.TryGetValue(id, out LineEntry? existing) && existing.Signature == candidate.Signature)
{
next[id] = existing;
@@ -192,11 +194,11 @@ public sealed class TaskManager
}
// The Agent receives a full task snapshot for peer/trust changes and target-only tasks too.
// Those changes do not affect a Probe's line blocks, so avoid retransmitting the same full
// line_task_set to every Probe. New sessions still get their initial snapshot in OpenSession.
// Those changes do not affect a 拨号方's line blocks, so avoid retransmitting the same full
// line_task_set to every 拨号方. New sessions still get their initial snapshot in OpenSession.
if (lineTasksChanged)
{
changes.Add((probe, SetFor(current)));
changes.Add((dialer, SetFor(current)));
}
}
}
@@ -207,15 +209,15 @@ public sealed class TaskManager
}
OnPeersChanged?.Invoke(new HashSet<string>(peers.Keys, StringComparer.Ordinal));
foreach ((ProbeSession probe, LineTaskSet next) in changes)
foreach ((DialerSession dialer, LineTaskSet next) in changes)
{
OnLineTasksChanged?.Invoke(probe, next);
OnLineTasksChanged?.Invoke(dialer, next);
}
return result;
}
/// <summary>停止时释放所有 Probe 会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。</summary>
/// <summary>停止时释放所有拨号方会话的流和所有目标任务(Backend 失联的时候任务继续运行:节点自治)。</summary>
public async Task StopAsync()
{
_targets.Reconcile([]);
@@ -238,13 +240,12 @@ public sealed class TaskManager
Version = _version,
Tasks = [.. entries.Values.Select(e => e.Task)],
QuicPorts = [.. _quicPorts()],
LegacyUdpPorts = [.. _quicPorts()],
TcpPorts = [.. _tcpPorts()],
};
private LineEntry BuildLineEntry(TaskSpec task, TaskDecision decision, ProbeSession probe)
private LineEntry BuildLineEntry(TaskSpec task, TaskDecision decision, DialerSession dialer)
{
var info = new StreamInfo(_streams.Allocate(), task.Kind, decision.Resolved!) { TaskId = task.Id, Owner = probe };
var info = new StreamInfo(_streams.Allocate(), task.Kind, decision.Resolved!) { TaskId = task.Id, Owner = dialer };
var lineTask = new LineTask
{
Id = task.Id,
@@ -254,7 +255,7 @@ public sealed class TaskManager
Port = info.Port,
};
var spec = new BlockSpec(decision.Kind!, decision.Resolved, info.Id, info.Port, new FragmentScope(task.Id, null, null));
return new LineEntry(lineTask, info, spec, _env.WithSession(probe));
return new LineEntry(lineTask, info, spec, _env.WithSession(dialer));
}
/// <summary>在数据面登记这个流并启动它的监听方积木。</summary>