Commit 00897d8
Eric Bower
·
2026-08-11 10:16:10 -0400 EDT
parent b7033a8
refactor: ZMX_TASK_COMPLETE now uses a 4ch hex id for task detection This ensures we are correctly scanning for the task that was actually triggered from the current daemon. A zmx session is allowed to `zmx run` to another session and then it immediately tails that command. That command will also output the ZMX_TASK_COMPLETE marker which could mark the parent session as complete erroneously. We now look for `ZMX_TASK_COMPLETE:wxyz:0`
3 files changed,
+32,
-14
+11,
-5
| ... | ... | @@ -362,7 +362,7 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f | |
| 362 | 362 | @memcpy(scan_buf[marker_carry_len..][0..n], buf[0..n]); | |
| 363 | 363 | const scan_len = marker_carry_len + n; | |
| 364 | 364 | ||
| 365 | - | if (util.findTaskExitMarker(scan_buf[0..scan_len])) |exit_code| { | |
| 365 | + | if (try util.findTaskExitMarker(scan_buf[0..scan_len], daemon.task_id)) |exit_code| { | |
| 366 | 366 | daemon.task_exit_code = exit_code; | |
| 367 | 367 | daemon.task_ended_at = @intCast(std.Io.Timestamp.now(io, .real).toSeconds()); | |
| 368 | 368 |
| ... | ... | @@ -474,7 +474,7 @@ fn daemonLoop(daemon: *Daemon, gpa: std.mem.Allocator, io: std.Io, server_sock_f | |
| 474 | 474 | .LabelSet => try daemon.handleLabelSet(gpa, client, msg.payload), | |
| 475 | 475 | .LabelClear => try daemon.handleLabelClear(gpa, client), | |
| 476 | 476 | .History => try daemon.handleHistory(gpa, client, &term, msg.payload), | |
| 477 | - | .Run => try daemon.handleRun(gpa, client, msg.payload), | |
| 477 | + | .Run => try daemon.handleRun(gpa, io, client, msg.payload), | |
| 478 | 478 | .Ack, .TaskComplete, .LabelData => {}, | |
| 479 | 479 | .Write => try daemon.handleWrite(gpa, client, msg.payload), | |
| 480 | 480 | _ => std.log.warn( |
| ... | ... | @@ -566,6 +566,7 @@ pub const Daemon = struct { | |
| 566 | 566 | has_terminal_client: bool = false, // true only after a real attach (.Init received) | |
| 567 | 567 | created_at: u64, // unix timestamp (ns) | |
| 568 | 568 | is_task_mode: bool = false, // flag for when session is run as a task | |
| 569 | + | task_id: [4]u8 = undefined, | |
| 569 | 570 | task_exit_code: ?u8 = null, // null = running or n/a, set when task completes | |
| 570 | 571 | task_ended_at: ?u64 = null, // timestamp when task exited | |
| 571 | 572 | pty_fd: i32 = -1, // set by daemonLoop so handleRun can probe the foreground process |
| ... | ... | @@ -1102,13 +1103,14 @@ pub const Daemon = struct { | |
| 1102 | 1103 | } | |
| 1103 | 1104 | } | |
| 1104 | 1105 | ||
| 1105 | - | pub fn handleRun(self: *Daemon, gpa: std.mem.Allocator, client: *Client, payload: []const u8) !void { | |
| 1106 | + | pub fn handleRun(self: *Daemon, gpa: std.mem.Allocator, io: std.Io, client: *Client, payload: []const u8) !void { | |
| 1106 | 1107 | // Reset task tracking so the new command's exit marker is detected. | |
| 1107 | 1108 | // Without this, a second `zmx run` on the same session is ignored | |
| 1108 | 1109 | // because task_exit_code is still set from the first run. | |
| 1109 | 1110 | self.task_exit_code = null; | |
| 1110 | 1111 | self.task_ended_at = null; | |
| 1111 | 1112 | self.is_task_mode = true; | |
| 1113 | + | self.task_id = util.generateTaskId(io); | |
| 1112 | 1114 | ||
| 1113 | 1115 | if (payload.len == 0) return; | |
| 1114 | 1116 |
| ... | ... | @@ -1118,8 +1120,12 @@ pub const Daemon = struct { | |
| 1118 | 1120 | // exit code of the command (not the `;`). The sole exception is when | |
| 1119 | 1121 | // the command contains a heredoc (`<<`), the delimiter must be alone | |
| 1120 | 1122 | // on its line, so the marker goes on the next line instead. | |
| 1121 | - | const single_line_marker = "; echo ZMX_TASK_COMPLETED:$?\r"; | |
| 1122 | - | const heredoc_marker = "\r\necho ZMX_TASK_COMPLETED:$?\r"; | |
| 1123 | + | var buf: [1024]u8 = undefined; | |
| 1124 | + | const marker = try util.getTaskExitMarker(&buf, self.task_id); | |
| 1125 | + | var single_buf: [1024]u8 = undefined; | |
| 1126 | + | const single_line_marker = try std.fmt.bufPrint(&single_buf, "; echo {s}$?\r", .{marker}); | |
| 1127 | + | var here_buf: [1024]u8 = undefined; | |
| 1128 | + | const heredoc_marker = try std.fmt.bufPrint(&here_buf, "\r\necho {s}$?\r", .{marker}); | |
| 1123 | 1129 | const uses_heredoc = std.mem.indexOf(u8, cmd, "<<") != null; | |
| 1124 | 1130 | ||
| 1125 | 1131 | if (cmd.len > 0 and cmd[cmd.len - 1] == '\r') { |
+6,
-5
| ... | ... | @@ -642,15 +642,16 @@ fn tail(alloc: std.mem.Allocator, client_socket_fds: std.ArrayList(i32), detache | |
| 642 | 642 | }, | |
| 643 | 643 | .Output => { | |
| 644 | 644 | if (msg.payload.len > 0) { | |
| 645 | + | // TODO: figure out how to bring this back | |
| 645 | 646 | // Fallback: scan output for task exit marker in case | |
| 646 | 647 | // .TaskComplete was lost (e.g. daemon exited before | |
| 647 | 648 | // flushing). This ensures we detect completion even | |
| 648 | 649 | // when the IPC message doesn't arrive. | |
| 649 | - | if (task_complete_code == null and is_run_cmd) { | |
| 650 | - | if (util.findTaskExitMarker(msg.payload)) |ec| { | |
| 651 | - | task_complete_code = ec; | |
| 652 | - | } | |
| 653 | - | } | |
| 650 | + | // if (task_complete_code == null and is_run_cmd) { | |
| 651 | + | // if (util.findTaskExitMarker(msg.payload)) |ec| { | |
| 652 | + | // task_complete_code = ec; | |
| 653 | + | // } | |
| 654 | + | // } | |
| 654 | 655 | ||
| 655 | 656 | // Strip the first line (command echo) for run mode. | |
| 656 | 657 | var payload = msg.payload; |
+15,
-4
| ... | ... | @@ -350,13 +350,24 @@ test "rewritePromptRedraw: embedded in larger output" { | |
| 350 | 350 | try std.testing.expectEqualStrings("some output\r\n\x1b]133;A;redraw=0\x07prompt$ \x1b]133;B\x07", result); | |
| 351 | 351 | } | |
| 352 | 352 | ||
| 353 | - | pub fn findTaskExitMarker(output: []const u8) ?u8 { | |
| 354 | - | const marker = "ZMX_TASK_COMPLETED:"; | |
| 353 | + | pub fn generateTaskId(io: std.Io) [4]u8 { | |
| 354 | + | var bytes: [2]u8 = undefined; | |
| 355 | + | io.random(&bytes); | |
| 356 | + | return std.fmt.bytesToHex(bytes, .lower); | |
| 357 | + | } | |
| 358 | + | ||
| 359 | + | pub fn getTaskExitMarker(buf: []u8, id_marker: [4]u8) ![]u8 { | |
| 360 | + | return std.fmt.bufPrint(buf, "ZMX_TASK_COMPLETED:{s}:", .{id_marker}); | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | pub fn findTaskExitMarker(output: []const u8, id_marker: [4]u8) !?u8 { | |
| 364 | + | var buf: [1024]u8 = undefined; | |
| 365 | + | const marker = try getTaskExitMarker(&buf, id_marker); | |
| 355 | 366 | ||
| 356 | 367 | // The command line is echoed back by the PTY (canonical mode) before the | |
| 357 | 368 | // shell evaluates it, so the *first* occurrence of the marker in the | |
| 358 | - | // output is often the literal, unexpanded "ZMX_TASK_COMPLETED:$?" from | |
| 359 | - | // the echo, not the real "ZMX_TASK_COMPLETED:<code>" written once the | |
| 369 | + | // output is often the literal, unexpanded "ZMX_TASK_COMPLETED:{id}:$?" from | |
| 370 | + | // the echo, not the real "ZMX_TASK_COMPLETED:{id}:<code>" written once the | |
| 360 | 371 | // shell actually runs it. Keep scanning past unparseable occurrences | |
| 361 | 372 | // instead of giving up on the first one. | |
| 362 | 373 | var search_start: usize = 0; |