-
Notifications
You must be signed in to change notification settings - Fork 0
Expand file tree
/
Copy pathProgram.cs
More file actions
474 lines (433 loc) · 20.1 KB
/
Copy pathProgram.cs
File metadata and controls
474 lines (433 loc) · 20.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
using System.Text.Json;
namespace Tman;
public static partial class Program
{
static string Version
{
get
{
var v = System.Reflection.CustomAttributeExtensions
.GetCustomAttribute<System.Reflection.AssemblyInformationalVersionAttribute>(
System.Reflection.Assembly.GetExecutingAssembly())
?.InformationalVersion ?? "dev";
var plus = v.IndexOf('+');
return plus > 0 ? v[..plus] : v;
}
}
public static async Task<int> Main(string[] argv)
{
Store.EnsureDirs();
if (argv.Length == 0) { PrintUsage(); return 0; }
var cmd = argv[0];
var rest = argv[1..];
try
{
switch (cmd)
{
case "run": return await CmdRun(rest, null);
case "list" or "ls": return CmdList(rest);
case "kill": return CmdKill(rest);
case "clean": return CmdClean();
case "status": return CmdStatus(rest);
case "init": return CmdInit(rest);
case "hook": return CmdHook(rest);
case "--help" or "-h" or "help": PrintUsage(); return 0;
case "--version" or "-v": Console.WriteLine($"tman {Version}"); return 0;
default:
{
var config = Config.Load();
if (config is not null && config.Aliases.TryGetValue(cmd, out var alias))
return await RunAlias(alias, config, rest);
Console.Error.WriteLine($"tman: unknown command or alias '{cmd}'");
PrintUsage();
return Runner.ExitNotFound;
}
}
}
catch (FormatException e)
{
Console.Error.WriteLine($"tman: {e.Message}");
return Runner.ExitNotFound;
}
}
/// <summary>
/// Retention for automatic pruning. A broken config must not stop housekeeping, so a config
/// that fails to parse falls back to the built-in window rather than propagating.
/// </summary>
static TimeSpan Retention()
{
try { return Config.EffectiveCaps(null, new Caps(), Config.Load()).Retain ?? Reaper.DefaultRetain; }
catch (FormatException) { return Reaper.DefaultRetain; }
}
static async Task<int> RunAlias(AliasDef alias, TmanConfig config, string[] extraArgs)
{
Reaper.Sweep(Retention());
var args = alias.Args.Concat(extraArgs).ToArray();
var caps = Config.EffectiveCaps(alias, new Caps(), config);
return await GatedRun(alias.Command, args, caps, alias.Name, alias.Name, replace: false, RunKey.ScopeDir(config));
}
static async Task<int> CmdRun(string[] argv, string? _)
{
Reaper.Sweep(Retention());
string? name = null, aliasName = null;
var replace = false;
TimeSpan? capMaxTime = null, capStall = null, capQueueTimeout = null;
long? capMaxMemMb = null;
double? capMaxCpuPct = null;
int? capMaxParallel = null;
Caps CliCaps() => new()
{
MaxTime = capMaxTime, Stall = capStall, QueueTimeout = capQueueTimeout,
MaxMemMb = capMaxMemMb, MaxCpuPct = capMaxCpuPct, MaxParallel = capMaxParallel,
};
var cmdArgs = new List<string>();
var i = 0;
var sawDashDash = false;
for (; i < argv.Length; i++)
{
var a = argv[i];
if (!sawDashDash && a == "--") { sawDashDash = true; continue; }
if (!sawDashDash && a.StartsWith("--"))
{
string Next() => i + 1 < argv.Length ? argv[++i] : throw new FormatException($"flag {a} requires a value");
switch (a)
{
case "--name": name = Next(); break;
case "--alias": aliasName = Next(); break;
case "--replace": replace = true; break;
case "--max-time": capMaxTime = Caps.ParseDuration(Next()) ?? throw new FormatException("bad --max-time"); break;
case "--stall": capStall = Caps.ParseDuration(Next()) ?? throw new FormatException("bad --stall"); break;
case "--max-mem": capMaxMemMb = Caps.ParseMemMb(Next()) ?? throw new FormatException("bad --max-mem"); break;
case "--max-cpu": capMaxCpuPct = double.Parse(Next(), System.Globalization.CultureInfo.InvariantCulture); break;
case "--max-parallel": capMaxParallel = int.Parse(Next()); break;
case "--queue-timeout": capQueueTimeout = Caps.ParseDuration(Next()) ?? throw new FormatException("bad --queue-timeout"); break;
default: throw new FormatException($"unknown flag {a}");
}
continue;
}
cmdArgs.Add(a);
}
if (aliasName is not null)
{
var config = Config.Load()
?? throw new FormatException("no .tman.kdl found (run 'tman init')");
if (!config.Aliases.TryGetValue(aliasName, out var alias))
throw new FormatException($"alias '{aliasName}' not defined in {config.FilePath}");
var args = alias.Args.Concat(cmdArgs).ToArray();
var caps = Config.EffectiveCaps(alias, CliCaps(), config);
return await GatedRun(
alias.Command, args, caps, name ?? alias.Name, alias.Name, replace, RunKey.ScopeDir(config));
}
if (cmdArgs.Count == 0) throw new FormatException("run requires a command");
{
var config = Config.Load();
var caps = Config.EffectiveCaps(null, CliCaps(), config);
return await GatedRun(
cmdArgs[0], cmdArgs[1..].ToArray(), caps, name, null, replace, RunKey.ScopeDir(config));
}
}
internal static async Task<int> GatedRun(
string command, string[] args, Caps caps, string? name, string? alias, bool replace, string scopeDir)
{
command = Canon.ResolveCommand(command);
var group = RunKey.For(name, command, scopeDir);
// a supervised process that re-enters tman is one logical run, not a second claim on a slot
var nested = Environment.GetEnvironmentVariable(Runner.ParentIdEnvVar) is not null;
var queueTimeout = caps.QueueTimeout ?? TimeSpan.FromMinutes(5);
FileStream? lockFile = null;
if (name is not null)
{
// holding the name is the claim on it; a name held by a runner that died is free again
// the moment the kernel drops its handle, so nothing here breaks a lock to take it
lockFile = Store.TryAcquireNameLock(group);
if (lockFile is null)
{
var holder = Reaper.FindLiveInGroup(group);
var who = holder is not null ? $" (pid {holder.Pid}, id {holder.Id})" : "";
if (!replace)
{
Console.Error.WriteLine($"tman: run '{name}' already active{who}; use --replace to kill it");
return Runner.ExitKilled;
}
Console.Error.WriteLine($"tman: replacing run '{name}'{who}");
if (holder is not null)
{
ProcUtil.KillTree(holder.Pid);
holder.State = RunState.Killed;
holder.KillReason = "replaced by newer run";
Store.Save(holder);
}
// killing the child does not release the name — the runner that holds it does, as
// it winds up. Waiting for that is what replaces the old run instead of joining it.
lockFile = await AwaitNameLock(group, queueTimeout);
if (lockFile is null)
{
Console.Error.WriteLine($"tman: run '{name}' has not released its lock; not replacing it");
return Runner.ExitKilled;
}
}
// the name can still be held by a runner that died leaving its child alive: the kernel
// handed the name back, but the work it stood for is still running
var existing = Reaper.FindLiveInGroup(group);
if (existing is not null)
{
if (!replace)
{
Console.Error.WriteLine($"tman: run '{name}' already active (pid {existing.Pid}, id {existing.Id}); use --replace to kill it");
Store.ReleaseLock(lockFile);
return Runner.ExitKilled;
}
Console.Error.WriteLine($"tman: replacing run '{name}' (pid {existing.Pid})");
ProcUtil.KillTree(existing.Pid);
existing.State = RunState.Killed;
existing.KillReason = "replaced by newer run";
Store.Save(existing);
}
}
FileStream? slotFile = null;
try
{
if (!nested && caps.MaxParallel is { } maxPar && maxPar > 0)
{
var deadline = DateTime.UtcNow + queueTimeout;
// holding the slot file, rather than counting live runs, is what admits this run:
// every racer would read the same count, but only one can create the same file
while ((slotFile = Store.TryAcquireSlot(group, maxPar)) is null)
{
// a slot may be held by a live child whose runner died; free it before waiting on it
Reaper.ReapOrphans(quiet: true);
if (DateTime.UtcNow >= deadline)
{
Console.Error.WriteLine($"tman: queue timeout waiting for a '{group}' slot (all {maxPar} busy)");
return Runner.ExitKilled;
}
Console.Error.WriteLine($"tman: all {maxPar} '{group}' slots busy, waiting...");
await Task.Delay(2000);
}
}
return await Runner.RunAsync(command, args, caps, name, alias, group);
}
finally
{
if (slotFile is not null) Store.ReleaseLock(slotFile);
if (lockFile is not null) Store.ReleaseLock(lockFile);
}
}
/// <summary>
/// Waits for a name the caller has just told to go away, up to <paramref name="timeout"/>.
/// Returns null when it is still held then — reporting that is the honest end of a replace that
/// could not happen, where taking the name anyway is the unlink that let two runs share it.
/// </summary>
static async Task<FileStream?> AwaitNameLock(string group, TimeSpan timeout)
{
var deadline = DateTime.UtcNow + timeout;
while (true)
{
var claimed = Store.TryAcquireNameLock(group);
if (claimed is not null) return claimed;
if (DateTime.UtcNow >= deadline) return null;
await Task.Delay(50);
}
}
static int CmdList(string[] argv)
{
Reaper.Sweep(Retention(), quiet: true);
var all = argv.Contains("--all");
var runs = Store.LoadAll()
.Where(r => all || r.State == RunState.Running)
.OrderByDescending(r => r.StartedUtc)
.ToList();
if (runs.Count == 0) { Console.WriteLine("no runs"); return 0; }
var now = DateTime.UtcNow;
Console.WriteLine($"{"ID",-14}{"NAME",-16}{"PID",-8}{"STATE",-9}{"AGE",-8}{"PEAKMEM",-9}COMMAND");
foreach (var r in runs)
{
// a nested run is the same work as its parent, marked so the list is not read as two runs
var name = r.IsNested ? "└ " + (r.Name ?? "-") : r.Name ?? "-";
Console.WriteLine(
$"{r.Id,-14}{Canon.Ellipsize(name, 15),-16}{r.Pid,-8}" +
$"{StateLabel(r.State),-9}{Canon.Duration(now - r.StartedUtc),-8}" +
$"{Canon.Mem(r.PeakMemMb),-9}{Canon.CommandLine(r.Command, r.Args)}");
}
return 0;
}
static int CmdKill(string[] argv)
{
Reaper.Sweep(Retention(), quiet: true);
var staleOnly = argv.Contains("--stale-only");
var targets = argv.Where(a => !a.StartsWith("--")).ToList();
if (targets.Count == 0) throw new FormatException("kill requires <id|name|all>");
var killed = 0;
foreach (var target in targets)
{
IEnumerable<RunRecord> matches = target == "all"
? Reaper.LiveRuns()
: Reaper.LiveRuns().Where(r => r.Matches(target));
foreach (var r in matches)
{
if (staleOnly && r.RunnerPid > 0 && ProcUtil.IsAlive(r.RunnerPid))
continue;
Console.WriteLine($"tman: killing {r.Name ?? r.Id} (pid {r.Pid})");
ProcUtil.KillTree(r.Pid);
r.State = RunState.Killed;
r.KillReason = "killed via tman kill";
r.HeartbeatUtc = DateTime.UtcNow;
Store.Save(r);
killed++;
}
}
if (killed == 0) Console.WriteLine("no matching live runs");
return 0;
}
static int CmdClean()
{
var retain = Retention();
var (reaped, pruned) = Reaper.Sweep(retain);
Console.WriteLine(
$"tman: reaped {reaped.Count} orphan(s), pruned {pruned} record(s) older than " +
Canon.Duration(retain));
return 0;
}
static int CmdStatus(string[] argv)
{
Reaper.Sweep(Retention(), quiet: true);
var target = argv.FirstOrDefault(a => !a.StartsWith("--"));
var live = Reaper.LiveRuns();
if (target is null)
{
var counts = Store.LoadAll().GroupBy(r => r.State).ToDictionary(g => g.Key, g => g.Count());
Console.WriteLine($"live: {live.Count}");
foreach (var (state, count) in counts.OrderBy(kv => kv.Key.ToString()))
Console.WriteLine($"{StateLabel(state)}: {count}");
return 0;
}
var r = Reaper.Resolve(target);
if (r is null) { Console.Error.WriteLine($"tman: no run '{target}'"); return Runner.ExitNotFound; }
if (argv.Contains("--json"))
{
Console.WriteLine(RunRecordReport.ToJson(r));
return 0;
}
PrintRunDetail(r);
return 0;
}
static string StateLabel(RunState state) => state.ToString().ToLowerInvariant();
static void PrintRunDetail(RunRecord r)
{
var now = DateTime.UtcNow;
void Row(string label, string? value)
{
if (!string.IsNullOrEmpty(value)) Console.WriteLine($"{label + ":",-12}{value}");
}
Row("id", r.Id);
Row("name", r.Name);
Row("state", StateLabel(r.State) + (r.ExitCode is { } ec ? $" (exit {ec})" : ""));
Row("command", Canon.CommandLine(r.Command, r.Args, full: true));
Row("cwd", r.Cwd);
Row("bucket", r.Group);
Row("parent", r.ParentId);
Row("pid", $"{r.Pid} (runner {r.RunnerPid})");
Row("started", $"{r.StartedUtc:u} ({Canon.Duration(now - r.StartedUtc)} ago)");
Row("heartbeat", $"{r.HeartbeatUtc:u} ({Canon.Duration(now - r.HeartbeatUtc)} ago)");
Row("peak mem", Canon.Mem(r.PeakMemMb));
Row("caps", DescribeCaps(r.Caps));
Row("killed", r.KillReason);
}
static string DescribeCaps(Caps c)
{
var parts = new List<string>();
if (c.MaxTime is { } mt) parts.Add($"max-time {Canon.Duration(mt)}");
if (c.Stall is { } st) parts.Add($"stall {Canon.Duration(st)}");
if (c.MaxMemMb is { } mm) parts.Add($"max-mem {Canon.Mem(mm)}");
if (c.MaxCpuPct is { } mc) parts.Add($"max-cpu {mc:0.#}%");
if (c.MaxParallel is { } mp) parts.Add($"max-parallel {mp}");
return parts.Count == 0 ? "none" : string.Join(", ", parts);
}
internal static int CmdInit(string[] argv)
{
var dir = Directory.GetCurrentDirectory();
var path = Path.Combine(dir, Config.FileName);
var withShims = argv.Contains("--shims");
var withGitignore = argv.Contains("--gitignore");
var detected = DetectAliases(dir);
if (File.Exists(path))
{
Console.WriteLine($"tman: {Config.FileName} already exists");
}
else
{
File.WriteAllText(path, RenderConfig(detected));
Console.WriteLine($"tman: wrote {path}");
}
var names = detected.Count > 0
? detected.Select(a => a.Name).ToList()
: new List<string> { "test" };
if (withShims)
{
var (written, skipped) = Shim.Generate(dir, names);
foreach (var p in written)
Console.WriteLine($"tman: wrote shim {p}");
foreach (var p in skipped)
Console.WriteLine($"tman: skipped shim {p} (path already exists; use 'tman run' instead)");
}
if (withGitignore && Shim.AppendGitignore(dir, names))
Console.WriteLine("tman: updated .gitignore");
return 0;
}
/// <summary>
/// Claude Code PreToolUse hook: request JSON on stdin, response JSON on stdout. It never
/// returns 2 (the only blocking exit code) and never propagates an exception, because a hook
/// that cannot decide must let the user's command through unchanged.
/// </summary>
static int CmdHook(string[] argv)
{
var evt = argv.FirstOrDefault();
if (evt != "pretooluse")
{
Console.Error.WriteLine($"tman: unknown hook event '{evt ?? ""}' (expected 'pretooluse')");
return Runner.ExitNotFound;
}
try
{
var response = Hook.Render(
Console.In.ReadToEnd(), Environment.GetEnvironmentVariable(Runner.ParentIdEnvVar));
if (response.Length > 0) Console.Out.Write(response);
}
catch (Exception e)
{
Console.Error.WriteLine($"tman: hook error, command left unsupervised: {e.Message}");
}
return 0;
}
internal sealed record DetectedAlias(string Name, string Command, string[] Args);
static void PrintUsage() => Console.WriteLine("""
tman - AOT process/test runner manager
usage:
tman run [flags] -- <cmd> [args...] run a process under tman supervision
tman run --alias <name> [args...] run a .tman.kdl alias
tman <alias> [args...] shorthand for an alias
tman list|ls [--all] list live (or all) runs
tman kill <id|name|all> [--stale-only] kill run(s)
tman clean reap orphans, prune old records
tman status [id|name] [--json] summary or run detail
tman init [--shims] [--gitignore] scaffold .tman.kdl (+ shim scripts)
tman hook pretooluse Claude Code PreToolUse hook: reads the tool call
on stdin, re-issues bare test/build commands
through tman, never blocks
run flags:
--name N dedup lock name (per directory; fail if already running)
--replace kill the run holding the name, then wait for its runner to release
the name (up to --queue-timeout; refuses to start if still held)
--max-time T wall-clock limit (30s, 10m, 2h)
--stall T kill if no output or cpu/io/io-wait activity for T
--max-mem M kill above process-tree memory (4096, 2g)
--max-cpu P kill above P% sustained CPU
--max-parallel N queue until one of this bucket's N slot files can be held
(bucket: name-or-command @ dir)
--queue-timeout T give up queueing after T
every command sweeps: orphans (dead runner, live child) are killed, and finished records
past the retention window are pruned. Set the window with `retain` in .tman.kdl defaults
(24h by default). Lock files are not part of it — a bucket whose holder died is taken over
in place by the next run that claims it.
""");
}