@@ -20,6 +20,30 @@ const notify = @import("notify.zig");
2020/// Hard ceiling on parallel agents regardless of what the caller requests.
2121pub const HARD_MAX : u32 = 100 ;
2222
23+ // ── SIGINT handling for partial telemetry ──────────────────────────────────────
24+ var g_interrupted : std .atomic .Value (bool ) = std .atomic .Value (bool ).init (false );
25+
26+ fn sigintHandler (_ : c_int ) callconv (.c ) void {
27+ g_interrupted .store (true , .release );
28+ }
29+
30+ fn installSigintHandler () void {
31+ const act = std.posix.Sigaction {
32+ .handler = .{ .handler = sigintHandler },
33+ .mask = std .posix .sigemptyset (),
34+ .flags = 0 ,
35+ };
36+ std .posix .sigaction (std .posix .SIG .INT , & act , null );
37+ }
38+
39+ fn restoreDefaultSigint () void {
40+ const act = std.posix.Sigaction {
41+ .handler = .{ .handler = null },
42+ .mask = std .posix .sigemptyset (),
43+ .flags = 0 ,
44+ };
45+ std .posix .sigaction (std .posix .SIG .INT , & act , null );
46+ }
2347// ── Worker ────────────────────────────────────────────────────────────────────
2448
2549const Worker = struct {
@@ -134,6 +158,10 @@ pub fn runSwarm(
134158) void {
135159 const cap : usize = @min (max_agents , HARD_MAX );
136160
161+ // ── Install SIGINT handler for partial telemetry ──────────────────────────
162+ g_interrupted .store (false , .release );
163+ installSigintHandler ();
164+ defer restoreDefaultSigint ();
137165 // ── Phase 0: Announce swarm start ────────────────────────────────────────
138166 {
139167 var msg_buf : [256 ]u8 = undefined ;
@@ -323,7 +351,58 @@ pub fn runSwarm(
323351 if (w .allocated_prompt ) | p | alloc .free (p );
324352 }
325353
326- // ── Phase 3b: Capture file manifest for writable swarms ──────────────
354+ // ── Check for early interruption ─────────────────────────────────────────
355+ if (g_interrupted .load (.acquire )) {
356+ // Count how many workers actually completed successfully
357+ var completed : usize = 0 ;
358+ for (worker_metrics [0.. count ]) | m | {
359+ if (m .success ) completed += 1 ;
360+ }
361+
362+ if (completed > 0 ) {
363+ // Send partial telemetry — at least one agent finished
364+ var grid = telemetry .GridMetrics .init (alloc , "worker" );
365+ for (worker_metrics [0.. count ]) | * m | {
366+ m .* .role = workers [m .worker_id ].role ;
367+ m .* .model = workers [m .worker_id ].model ;
368+ grid .addWorker (alloc , m .* ) catch {};
369+ }
370+ swarm_telemetry .addGrid (grid ) catch {};
371+
372+ const telemetry_json = swarm_telemetry .toJson (alloc , true );
373+ if (telemetry_json .len > 0 ) {
374+ std .debug .print ("\n [telemetry:interrupted] {d}/{d} agents completed\n " , .{ completed , count });
375+ std .debug .print ("[telemetry] {s}\n " , .{telemetry_json });
376+ telemetry .upload (alloc , telemetry_json );
377+
378+ if (telemetry_out ) | path | {
379+ const file = if (std .fs .path .isAbsolute (path ))
380+ std .fs .createFileAbsolute (path , .{}) catch null
381+ else
382+ std .fs .cwd ().createFile (path , .{}) catch null ;
383+ if (file ) | f | {
384+ defer f .close ();
385+ f .writeAll (telemetry_json ) catch {};
386+ f .writeAll ("\n " ) catch {};
387+ }
388+ }
389+ alloc .free (telemetry_json );
390+ }
391+ } else {
392+ std .debug .print ("\n [telemetry:interrupted] no agents completed — skipping telemetry\n " , .{});
393+ }
394+
395+ // Write partial results to output
396+ for (workers [0.. count ]) | * w | {
397+ if (w .out .items .len > 0 ) {
398+ out .appendSlice (alloc , w .out .items ) catch {};
399+ out .appendSlice (alloc , "\n " ) catch {};
400+ }
401+ w .out .deinit (std .heap .page_allocator );
402+ }
403+ appendErr (alloc , out , "swarm interrupted by user (Ctrl+C)" );
404+ return ;
405+ }
327406 var manifest : []const u8 = "" ;
328407 var manifest_alloc : ? []u8 = null ;
329408 defer if (manifest_alloc ) | m | alloc .free (m );
@@ -397,7 +476,7 @@ pub fn runSwarm(
397476 swarm_telemetry .addGrid (grid ) catch {};
398477
399478
400- const telemetry_json = swarm_telemetry .toJson (alloc );
479+ const telemetry_json = swarm_telemetry .toJson (alloc , false );
401480 if (telemetry_json .len > 0 ) {
402481 std .debug .print ("\n [telemetry] {s}\n " , .{telemetry_json });
403482
@@ -461,4 +540,42 @@ test "swarm: appendErr writes JSON error object" {
461540 try std .testing .expect (parsed .value == .object );
462541 const msg = parsed .value .object .get ("error" ) orelse return error .MissingError ;
463542 try std .testing .expectEqualStrings ("something went wrong" , msg .string );
543+ try std .testing .expectEqualStrings ("something went wrong" , msg .string );
544+ }
545+
546+ test "swarm: g_interrupted starts false" {
547+ try std .testing .expect (! g_interrupted .load (.acquire ));
548+ }
549+
550+ test "swarm: sigint handler sets interrupted flag" {
551+ // Reset to known state
552+ g_interrupted .store (false , .release );
553+ try std .testing .expect (! g_interrupted .load (.acquire ));
554+
555+ // Simulate what the signal handler does
556+ sigintHandler (0 );
557+
558+ try std .testing .expect (g_interrupted .load (.acquire ));
559+
560+ // Reset
561+ g_interrupted .store (false , .release );
562+ }
563+
564+ test "swarm: install and restore sigint handler" {
565+ // Should not crash
566+ installSigintHandler ();
567+ restoreDefaultSigint ();
568+ }
569+
570+ test "swarm: sigint handler is idempotent" {
571+ g_interrupted .store (false , .release );
572+
573+ // Multiple signals should not cause issues
574+ sigintHandler (0 );
575+ sigintHandler (0 );
576+ sigintHandler (0 );
577+
578+ try std .testing .expect (g_interrupted .load (.acquire ));
579+
580+ g_interrupted .store (false , .release );
464581}
0 commit comments