relay-codex 0.5.0 → 0.5.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/docs/commands.md +6 -0
- package/docs/publishing.md +2 -2
- package/package.json +2 -2
- package/python/Menu.swift +82 -12
- package/python/commands.py +19 -7
- package/python/router.py +27 -6
- package/python/service.py +75 -2
- package/tests/test_audit.py +111 -0
- package/tests/test_features.py +25 -0
package/docs/commands.md
CHANGED
|
@@ -97,6 +97,8 @@ The watch view waits for local file changes and redraws only when useful status
|
|
|
97
97
|
changes. Heartbeats do not repeatedly print the table. Watching uses no model
|
|
98
98
|
allowance and does not fetch account allowance. `refresh` explicitly checks it.
|
|
99
99
|
The menu uses one local status stream, a template icon, and native session actions.
|
|
100
|
+
Opening it reads a fresh local snapshot, and visible rows update in place.
|
|
101
|
+
Short-window and weekly allowance are labeled separately.
|
|
100
102
|
It opens at Mac login after `relay menubar`; disable that in its menu or with
|
|
101
103
|
`relay menubar --no-startup --configure-only`.
|
|
102
104
|
|
|
@@ -122,6 +124,10 @@ A workspace saves the same supervised session IDs, rather than starting fresh
|
|
|
122
124
|
conversations. Restore restarts stopped jobs and prints reconnect commands; live
|
|
123
125
|
jobs are retained. Save specific sessions by appending their IDs to `workspace save`.
|
|
124
126
|
|
|
127
|
+
Foreground jobs close after their launcher disappears and current activity is
|
|
128
|
+
idle. Explicit background tasks and takeovers remain running. An interrupted action
|
|
129
|
+
is retained for review rather than being silently retried.
|
|
130
|
+
|
|
125
131
|
The normal command remains available after reboot. Background task recovery at
|
|
126
132
|
login is a separate opt-in setting; enabling the menu does not enable task startup.
|
|
127
133
|
Uncertain work still pauses for review after recovery.
|
package/docs/publishing.md
CHANGED
|
@@ -8,8 +8,8 @@ Friends can run `npx relay-codex` or install it with `npm install -g relay-codex
|
|
|
8
8
|
The GitHub repository is enough for friends to install or try Relay:
|
|
9
9
|
|
|
10
10
|
```sh
|
|
11
|
-
npm install -g github:blackspider-ops/relay#v0.5.
|
|
12
|
-
npx --yes github:blackspider-ops/relay#v0.5.
|
|
11
|
+
npm install -g github:blackspider-ops/relay#v0.5.2
|
|
12
|
+
npx --yes github:blackspider-ops/relay#v0.5.2 --version
|
|
13
13
|
```
|
|
14
14
|
|
|
15
15
|
`npx` runs packages; it is not a separate publishing service. Publishing
|
package/package.json
CHANGED
|
@@ -1,12 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "relay-codex",
|
|
3
|
-
"version": "0.5.
|
|
3
|
+
"version": "0.5.2",
|
|
4
4
|
"description": "Keep your Codex sessions moving between your own accounts.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "blackspider-ops",
|
|
7
7
|
"repository": {
|
|
8
8
|
"type": "git",
|
|
9
|
-
"url": "https://github.com/blackspider-ops/relay.git"
|
|
9
|
+
"url": "git+https://github.com/blackspider-ops/relay.git"
|
|
10
10
|
},
|
|
11
11
|
"homepage": "https://github.com/blackspider-ops/relay#readme",
|
|
12
12
|
"bugs": {
|
package/python/Menu.swift
CHANGED
|
@@ -8,6 +8,7 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
8
8
|
var message: String?
|
|
9
9
|
var reconnect: DispatchWorkItem?
|
|
10
10
|
var stopping = false
|
|
11
|
+
var snapshotInFlight = false
|
|
11
12
|
let command = Bundle.main.object(forInfoDictionaryKey: "RelayCommand") as? String ?? "/usr/bin/false"
|
|
12
13
|
let state = Bundle.main.object(forInfoDictionaryKey: "RelayState") as? String ?? ""
|
|
13
14
|
|
|
@@ -61,10 +62,56 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
61
62
|
while let newline = buffer.firstIndex(of: 10) {
|
|
62
63
|
let line = buffer.prefix(upTo: newline); buffer.removeSubrange(...newline)
|
|
63
64
|
if let value = (try? JSONSerialization.jsonObject(with: line)) as? [String: Any] {
|
|
64
|
-
current = value; message = nil; render()
|
|
65
|
+
if (value["at"] as? Double ?? 0) >= (current["at"] as? Double ?? 0) { current = value; message = nil; render() }
|
|
65
66
|
}
|
|
66
67
|
}
|
|
67
68
|
}
|
|
69
|
+
func refreshSnapshot() {
|
|
70
|
+
guard !snapshotInFlight, !stopping else { return }
|
|
71
|
+
snapshotInFlight = true
|
|
72
|
+
let proc = process(["status", "--json"])
|
|
73
|
+
let pipe = Pipe(); proc.standardOutput = pipe; proc.standardError = FileHandle.nullDevice
|
|
74
|
+
DispatchQueue.global(qos: .utility).async { [weak self] in
|
|
75
|
+
var snapshot: [String: Any]?
|
|
76
|
+
do {
|
|
77
|
+
try proc.run()
|
|
78
|
+
let timeout = DispatchWorkItem { if proc.isRunning { proc.terminate() } }
|
|
79
|
+
DispatchQueue.global(qos: .utility).asyncAfter(deadline: .now() + 8, execute: timeout)
|
|
80
|
+
let bytes = pipe.fileHandleForReading.readDataToEndOfFile(); proc.waitUntilExit(); timeout.cancel()
|
|
81
|
+
if proc.terminationStatus == 0 { snapshot = (try? JSONSerialization.jsonObject(with: bytes)) as? [String: Any] }
|
|
82
|
+
} catch { }
|
|
83
|
+
let result = snapshot
|
|
84
|
+
DispatchQueue.main.async {
|
|
85
|
+
guard let self = self else { return }
|
|
86
|
+
self.snapshotInFlight = false
|
|
87
|
+
if let value = result {
|
|
88
|
+
let at = value["at"] as? Double ?? 0
|
|
89
|
+
if at >= (self.current["at"] as? Double ?? 0) { self.current = value; self.render() }
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
static func synchronise(_ live: NSMenu, with desired: NSMenu) {
|
|
95
|
+
let incoming = desired.items
|
|
96
|
+
while live.items.count > incoming.count { live.removeItem(at: live.items.count - 1) }
|
|
97
|
+
for (index, fresh) in incoming.enumerated() {
|
|
98
|
+
if index >= live.items.count {
|
|
99
|
+
desired.removeItem(fresh); live.addItem(fresh); continue
|
|
100
|
+
}
|
|
101
|
+
let existing = live.items[index]
|
|
102
|
+
if existing.isSeparatorItem != fresh.isSeparatorItem || existing.identifier != fresh.identifier {
|
|
103
|
+
live.removeItem(at: index); desired.removeItem(fresh); live.insertItem(fresh, at: index); continue
|
|
104
|
+
}
|
|
105
|
+
existing.title = fresh.title; existing.state = fresh.state
|
|
106
|
+
existing.target = fresh.target; existing.action = fresh.action
|
|
107
|
+
existing.keyEquivalent = fresh.keyEquivalent; existing.isEnabled = fresh.isEnabled
|
|
108
|
+
existing.representedObject = fresh.representedObject; existing.toolTip = fresh.toolTip
|
|
109
|
+
if let child = fresh.submenu {
|
|
110
|
+
if let previous = existing.submenu { synchronise(previous, with: child) }
|
|
111
|
+
else { fresh.submenu = nil; existing.submenu = child }
|
|
112
|
+
} else { existing.submenu = nil }
|
|
113
|
+
}
|
|
114
|
+
}
|
|
68
115
|
func updateIcon(attention: Bool) {
|
|
69
116
|
let symbol = attention ? "exclamationmark.circle.fill" : "arrow.triangle.branch"
|
|
70
117
|
let image = NSImage(systemSymbolName: symbol, accessibilityDescription: attention ? "Relay needs attention" : "Relay")
|
|
@@ -72,11 +119,13 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
72
119
|
item.button?.image = image
|
|
73
120
|
}
|
|
74
121
|
func text(_ title: String, into menu: NSMenu) {
|
|
75
|
-
|
|
122
|
+
let entry = NSMenuItem(title: title, action: nil, keyEquivalent: "")
|
|
123
|
+
entry.isEnabled = false; menu.addItem(entry)
|
|
76
124
|
}
|
|
77
125
|
func action(_ title: String, args: [String], into menu: NSMenu) {
|
|
78
126
|
let entry = NSMenuItem(title: title, action: #selector(runAction(_:)), keyEquivalent: "")
|
|
79
|
-
entry.target = self; entry.representedObject = args
|
|
127
|
+
entry.target = self; entry.representedObject = args
|
|
128
|
+
entry.identifier = NSUserInterfaceItemIdentifier("action:" + args.joined(separator: "|")); menu.addItem(entry)
|
|
80
129
|
}
|
|
81
130
|
func date(_ value: Any?) -> String {
|
|
82
131
|
guard let seconds = value as? Double else { return "unknown" }
|
|
@@ -110,23 +159,28 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
110
159
|
for account in accounts {
|
|
111
160
|
let name = account["name"] as? String ?? "Account"
|
|
112
161
|
let primary = account["primary"] as? [String: Any]
|
|
113
|
-
let
|
|
114
|
-
let
|
|
162
|
+
let short = account["short_remaining"] as? String ?? (primary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))%" } ?? "?"
|
|
163
|
+
let remaining = short == "?" ? "allowance unknown" : short + " left"
|
|
164
|
+
let secondary = account["secondary"] as? [String: Any]
|
|
165
|
+
let weekly = account["weekly_remaining"] as? String ?? (secondary?["usedPercent"] as? NSNumber).map { "\(max(0, 100 - $0.intValue))%" } ?? "?"
|
|
166
|
+
let label = (account["enabled"] as? Bool ?? true) ? "Short \(short) · Week \(weekly)" : "disabled"
|
|
115
167
|
let submenu = NSMenu()
|
|
116
168
|
if let email = account["email"] as? String { text(email, into: submenu) }
|
|
117
169
|
text("Short window: \(remaining)", into: submenu)
|
|
118
|
-
if
|
|
170
|
+
if weekly != "?" { text("Weekly window: \(weekly) left", into: submenu) }
|
|
119
171
|
if let reset = account["next_check"] { countdown("Next check", value: reset, into: submenu) }
|
|
120
172
|
text("Last checked: \(date(account["usage_checked_at"]))", into: submenu)
|
|
121
|
-
let entry = NSMenuItem(title: "\(name) · \(label)", action: nil, keyEquivalent: "")
|
|
173
|
+
let entry = NSMenuItem(title: "\(name) · \(label)", action: nil, keyEquivalent: "")
|
|
174
|
+
entry.identifier = NSUserInterfaceItemIdentifier("account:" + (account["id"] as? String ?? name))
|
|
175
|
+
entry.submenu = submenu; menu.addItem(entry)
|
|
122
176
|
}
|
|
123
|
-
menu.addItem(.separator())
|
|
177
|
+
if !sessions.isEmpty { menu.addItem(.separator()) }
|
|
124
178
|
for session in sessions {
|
|
125
179
|
let id = session["id"] as? String ?? ""
|
|
126
180
|
let name = session["name"] as? String ?? String(id.prefix(10))
|
|
127
181
|
let status = (session["state"] as? String ?? "unknown").replacingOccurrences(of: "_", with: " ")
|
|
128
182
|
let submenu = NSMenu()
|
|
129
|
-
text("Account: \(session["account"] as? String ?? "unknown")", into: submenu)
|
|
183
|
+
text("Account: \(session["account_name"] as? String ?? session["account"] as? String ?? "unknown")", into: submenu)
|
|
130
184
|
text("Model: \(session["model"] as? String ?? "unknown")", into: submenu)
|
|
131
185
|
if let next = session["next_wake"] { countdown("Next action", value: next, into: submenu) }
|
|
132
186
|
if let reason = session["reason"] as? String { text(reason.replacingOccurrences(of: "_", with: " "), into: submenu) }
|
|
@@ -134,7 +188,9 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
134
188
|
action("Pause recovery", args: ["pause", id, "--wait"], into: submenu)
|
|
135
189
|
action("Resume recovery", args: ["unpause", id, "--wait"], into: submenu)
|
|
136
190
|
action("Review and retry…", args: ["confirm-retry", id], into: submenu)
|
|
137
|
-
let entry = NSMenuItem(title: "\(name) · \(status)", action: nil, keyEquivalent: "")
|
|
191
|
+
let entry = NSMenuItem(title: "\(name) · \(status)", action: nil, keyEquivalent: "")
|
|
192
|
+
entry.identifier = NSUserInterfaceItemIdentifier("session:" + id)
|
|
193
|
+
entry.submenu = submenu; menu.addItem(entry)
|
|
138
194
|
}
|
|
139
195
|
menu.addItem(.separator())
|
|
140
196
|
action("Copy dashboard command", args: ["copy", "relay status --watch"], into: menu)
|
|
@@ -143,9 +199,11 @@ final class MenuController: NSObject, NSApplicationDelegate, NSMenuDelegate {
|
|
|
143
199
|
login.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
|
|
144
200
|
menu.addItem(login)
|
|
145
201
|
let quit = NSMenuItem(title: "Quit Relay menu", action: #selector(quitMenu), keyEquivalent: "q"); quit.target = self; menu.addItem(quit)
|
|
146
|
-
item.menu
|
|
202
|
+
if let visible = item.menu { MenuController.synchronise(visible, with: menu) }
|
|
203
|
+
else { item.menu = menu }
|
|
147
204
|
}
|
|
148
205
|
func menuWillOpen(_ menu: NSMenu) {
|
|
206
|
+
refreshSnapshot()
|
|
149
207
|
updateTimes(menu)
|
|
150
208
|
for entry in menu.items where entry.title == "Open at login" {
|
|
151
209
|
entry.state = FileManager.default.fileExists(atPath: NSHomeDirectory() + "/Library/LaunchAgents/local.relay.menu.plist") ? .on : .off
|
|
@@ -181,7 +239,19 @@ if CommandLine.arguments.contains("--self-check") {
|
|
|
181
239
|
let normal = NSImage(systemSymbolName: "arrow.triangle.branch", accessibilityDescription: "Relay")
|
|
182
240
|
let attention = NSImage(systemSymbolName: "exclamationmark.circle.fill", accessibilityDescription: "Relay needs attention")
|
|
183
241
|
print("Relay menu symbols available: \(normal != nil && attention != nil)")
|
|
184
|
-
|
|
242
|
+
let visible = NSMenu(); visible.addItem(NSMenuItem(title: "Short 83%", action: nil, keyEquivalent: ""))
|
|
243
|
+
let existing = visible.items[0]
|
|
244
|
+
let updated = NSMenu(); updated.addItem(NSMenuItem(title: "Short 30%", action: nil, keyEquivalent: ""))
|
|
245
|
+
MenuController.synchronise(visible, with: updated)
|
|
246
|
+
let liveUpdate = visible.items[0] === existing && existing.title == "Short 30%"
|
|
247
|
+
print("Visible menu update preserved: \(liveUpdate)")
|
|
248
|
+
existing.identifier = NSUserInterfaceItemIdentifier("session:old")
|
|
249
|
+
let replacement = NSMenu(); let newItem = NSMenuItem(title: "New task", action: nil, keyEquivalent: "")
|
|
250
|
+
newItem.identifier = NSUserInterfaceItemIdentifier("session:new"); replacement.addItem(newItem)
|
|
251
|
+
MenuController.synchronise(visible, with: replacement)
|
|
252
|
+
let safeIdentity = visible.items[0] !== existing && visible.items[0].identifier == newItem.identifier
|
|
253
|
+
print("Session action identity preserved: \(safeIdentity)")
|
|
254
|
+
exit(normal != nil && attention != nil && liveUpdate && safeIdentity ? 0 : 1)
|
|
185
255
|
} else {
|
|
186
256
|
let app = NSApplication.shared
|
|
187
257
|
let delegate = MenuController(); app.delegate = delegate; app.run()
|
package/python/commands.py
CHANGED
|
@@ -68,6 +68,13 @@ def resolve_label(state, value):
|
|
|
68
68
|
raise RouterError("Unknown or ambiguous account name")
|
|
69
69
|
|
|
70
70
|
|
|
71
|
+
def remaining_percent(window):
|
|
72
|
+
if not window or not isinstance(window.get("usedPercent"), (int, float)):
|
|
73
|
+
return "?"
|
|
74
|
+
value = max(0, min(100, 100 - window["usedPercent"]))
|
|
75
|
+
return f"{value:.1f}".rstrip("0").rstrip(".") + "%"
|
|
76
|
+
|
|
77
|
+
|
|
71
78
|
def snapshot(state, include_inactive=False):
|
|
72
79
|
from router import labels, VERSION
|
|
73
80
|
|
|
@@ -99,6 +106,8 @@ def snapshot(state, include_inactive=False):
|
|
|
99
106
|
"sessions": sum(s["account"] == label for s in sessions),
|
|
100
107
|
"primary": windows.get("primary"),
|
|
101
108
|
"secondary": windows.get("secondary"),
|
|
109
|
+
"short_remaining": remaining_percent(windows.get("primary")),
|
|
110
|
+
"weekly_remaining": remaining_percent(windows.get("secondary")),
|
|
102
111
|
"usage_known": usage is not None,
|
|
103
112
|
"usage_checked_at": pool.checked_at(label),
|
|
104
113
|
"maintenance": options.get("maintenance", False),
|
|
@@ -110,6 +119,13 @@ def snapshot(state, include_inactive=False):
|
|
|
110
119
|
)
|
|
111
120
|
with pool.db() as db:
|
|
112
121
|
for session in sessions:
|
|
122
|
+
account = next(
|
|
123
|
+
(a for a in accounts if a["id"] == session.get("account")), {}
|
|
124
|
+
)
|
|
125
|
+
session["account_name"] = account.get(
|
|
126
|
+
"name", session.get("account", "")
|
|
127
|
+
)
|
|
128
|
+
session["account_email"] = account.get("email")
|
|
113
129
|
saved_intent = db.execute(
|
|
114
130
|
"SELECT details FROM intents WHERE owner=? AND thread=?",
|
|
115
131
|
(session["id"], session.get("thread", "")),
|
|
@@ -165,10 +181,6 @@ def snapshot(state, include_inactive=False):
|
|
|
165
181
|
|
|
166
182
|
|
|
167
183
|
def render(value):
|
|
168
|
-
def percent(window):
|
|
169
|
-
return (
|
|
170
|
-
"?" if not window else str(max(0, 100 - window.get("usedPercent", 0))) + "%"
|
|
171
|
-
)
|
|
172
184
|
|
|
173
185
|
lines = [
|
|
174
186
|
f"Relay · {len(value['sessions'])} sessions · {len(value['accounts'])} accounts",
|
|
@@ -193,16 +205,16 @@ def render(value):
|
|
|
193
205
|
else "—"
|
|
194
206
|
)
|
|
195
207
|
lines.append(
|
|
196
|
-
f"{label:<39}{state:<13}{
|
|
208
|
+
f"{label:<39}{state:<13}{account.get('short_remaining', remaining_percent(account['primary'])):<13}{account.get('weekly_remaining', remaining_percent(account['secondary'])):<13}{account['sessions']:<10}{next_at}"
|
|
197
209
|
)
|
|
198
210
|
if value["sessions"]:
|
|
199
211
|
lines += [
|
|
200
212
|
"",
|
|
201
|
-
f"{'SESSION':<12}{'ACCOUNT':<
|
|
213
|
+
f"{'SESSION':<12}{'ACCOUNT':<32}{'STATE':<23}{'MODEL':<23}PROJECT / NAME",
|
|
202
214
|
]
|
|
203
215
|
for row in value["sessions"]:
|
|
204
216
|
lines.append(
|
|
205
|
-
f"{row['id'][:10]:<12}{row.get('account', ''):<
|
|
217
|
+
f"{row['id'][:10]:<12}{str(row.get('account_name') or row.get('account', '')):<32}{str(row.get('state', 'unknown'))[:22]:<23}{str(row.get('model') or '?')[:22]:<23}{row.get('cwd') or row.get('name') or ''}"
|
|
206
218
|
)
|
|
207
219
|
if row.get("next_wake"):
|
|
208
220
|
lines.append(
|
package/python/router.py
CHANGED
|
@@ -24,7 +24,7 @@ from pool import Pool, blocked, reset_time, failure_kind, file_lock
|
|
|
24
24
|
from launch import translate, RESUME_FIELDS
|
|
25
25
|
import settings
|
|
26
26
|
|
|
27
|
-
VERSION = "0.5.
|
|
27
|
+
VERSION = "0.5.2"
|
|
28
28
|
_support = Path.home() / "Library/Application Support"
|
|
29
29
|
_legacy = _support / "Codex Accounts"
|
|
30
30
|
_default_state = _legacy if (_legacy / "accounts").exists() else _support / "Relay"
|
|
@@ -427,6 +427,8 @@ class Controller:
|
|
|
427
427
|
self.stopping = asyncio.Event()
|
|
428
428
|
self.extra_accounts = None
|
|
429
429
|
self.starting = False
|
|
430
|
+
self.lifecycle_check = None
|
|
431
|
+
self.exit_state = "stopped"
|
|
430
432
|
self.on_thread = None
|
|
431
433
|
self.on_detach = None
|
|
432
434
|
self.project = os.getcwd()
|
|
@@ -435,6 +437,17 @@ class Controller:
|
|
|
435
437
|
self.awake_process = None
|
|
436
438
|
self.fallback_tried = {}
|
|
437
439
|
|
|
440
|
+
def account_name(self):
|
|
441
|
+
if self.pool:
|
|
442
|
+
from commands import account_identity
|
|
443
|
+
|
|
444
|
+
return (
|
|
445
|
+
account_identity(self.pool.state, self.account.label).get("email")
|
|
446
|
+
or self.pool.options(self.account.label).get("name")
|
|
447
|
+
or self.account.label
|
|
448
|
+
)
|
|
449
|
+
return self.account.label
|
|
450
|
+
|
|
438
451
|
def attached(self):
|
|
439
452
|
return bool(self.client) or self.background or self.starting
|
|
440
453
|
|
|
@@ -457,7 +470,7 @@ class Controller:
|
|
|
457
470
|
if self.preferences.get("notifications"):
|
|
458
471
|
from notifications import notify
|
|
459
472
|
|
|
460
|
-
notify(kind, self.
|
|
473
|
+
notify(kind, self.account_name())
|
|
461
474
|
|
|
462
475
|
def merge_settings(self, thread, params):
|
|
463
476
|
allowed = {
|
|
@@ -514,6 +527,8 @@ class Controller:
|
|
|
514
527
|
|
|
515
528
|
def clear_review(self, thread):
|
|
516
529
|
self.paused_uncertain.discard(thread)
|
|
530
|
+
if not self.paused_uncertain:
|
|
531
|
+
self.exit_state = "stopped"
|
|
517
532
|
self.persist(thread, review_required=False, review_reason=None)
|
|
518
533
|
|
|
519
534
|
def can_continue(self, thread, generation):
|
|
@@ -670,6 +685,10 @@ class Controller:
|
|
|
670
685
|
except Exception:
|
|
671
686
|
self.pool.ack(command, "failed")
|
|
672
687
|
self.note("control_failed", operation=op)
|
|
688
|
+
if self.lifecycle_check:
|
|
689
|
+
await self.lifecycle_check()
|
|
690
|
+
if self.stopping.is_set():
|
|
691
|
+
break
|
|
673
692
|
if self.preferences.get("keep_awake") and self.active_threads:
|
|
674
693
|
if (
|
|
675
694
|
self.awake_process is None
|
|
@@ -727,6 +746,7 @@ class Controller:
|
|
|
727
746
|
self.wake.set()
|
|
728
747
|
self.note("resumed")
|
|
729
748
|
elif op == "stop":
|
|
749
|
+
self.exit_state = "stopped"
|
|
730
750
|
self.stopping.set()
|
|
731
751
|
elif op == "detach":
|
|
732
752
|
if self.pool:
|
|
@@ -792,6 +812,8 @@ class Controller:
|
|
|
792
812
|
self.note("switched", to=label)
|
|
793
813
|
elif op == "retry":
|
|
794
814
|
self.paused = False
|
|
815
|
+
if thread:
|
|
816
|
+
self.clear_review(thread)
|
|
795
817
|
self.wake.set()
|
|
796
818
|
if self.recovery and not self.recovery.done():
|
|
797
819
|
self.force_retry.set()
|
|
@@ -803,7 +825,6 @@ class Controller:
|
|
|
803
825
|
)
|
|
804
826
|
self.pool.invalidate(self.account.label)
|
|
805
827
|
if thread and (not self.recovery or self.recovery.done()):
|
|
806
|
-
self.clear_review(thread)
|
|
807
828
|
self.recovery = asyncio.create_task(
|
|
808
829
|
self.continue_thread(thread, self.user_generation)
|
|
809
830
|
)
|
|
@@ -1173,7 +1194,7 @@ class Controller:
|
|
|
1173
1194
|
raise
|
|
1174
1195
|
except (RouterError, asyncio.TimeoutError):
|
|
1175
1196
|
return await self.uncertain(thread, generation, baseline)
|
|
1176
|
-
self.report(f"Continued the saved thread with {self.
|
|
1197
|
+
self.report(f"Continued the saved thread with {self.account_name()}.")
|
|
1177
1198
|
|
|
1178
1199
|
async def restore_recovery(self, thread, persisted):
|
|
1179
1200
|
"""Honor an accepted retry's original deadline after a process restart."""
|
|
@@ -1383,7 +1404,7 @@ class Controller:
|
|
|
1383
1404
|
episode["attempt"] += 1
|
|
1384
1405
|
target = episode["start"] + episode["attempt"] * self.retry_interval
|
|
1385
1406
|
self.report(
|
|
1386
|
-
f"Service/authentication failure on {self.
|
|
1407
|
+
f"Service/authentication failure on {self.account_name()}. Retry {episode['attempt']}/{self.retry_attempts} at {time.strftime('%H:%M:%S', time.localtime(target))}."
|
|
1387
1408
|
)
|
|
1388
1409
|
self.note(
|
|
1389
1410
|
"retry_scheduled",
|
|
@@ -1416,7 +1437,7 @@ class Controller:
|
|
|
1416
1437
|
)
|
|
1417
1438
|
self.episodes.pop(thread, None)
|
|
1418
1439
|
self.report(
|
|
1419
|
-
f"Selecting another account after {kind} failure on {self.
|
|
1440
|
+
f"Selecting another account after {kind} failure on {self.account_name()}."
|
|
1420
1441
|
)
|
|
1421
1442
|
self.note("selecting_account", thread=thread, reason=kind)
|
|
1422
1443
|
await self.mark(self.index, model, kind)
|
package/python/service.py
CHANGED
|
@@ -51,6 +51,10 @@ def start(
|
|
|
51
51
|
"interactive": interactive,
|
|
52
52
|
"binary": binary,
|
|
53
53
|
"relay_runtime": str(Path(__file__).parent.resolve()),
|
|
54
|
+
"foreground_owner": {"pid": os.getpid(), "birth": process_birth(os.getpid())}
|
|
55
|
+
if interactive
|
|
56
|
+
else None,
|
|
57
|
+
"background_launch": not interactive,
|
|
54
58
|
}
|
|
55
59
|
if not Path(spec["cwd"]).is_dir():
|
|
56
60
|
raise ValueError("Working directory does not exist")
|
|
@@ -155,6 +159,21 @@ async def attach(
|
|
|
155
159
|
spec_path = root / (ident + ".job.json")
|
|
156
160
|
spec = json.loads(spec_path.read_text())
|
|
157
161
|
binary = spec.get("binary") or binary
|
|
162
|
+
owner = spec.get("foreground_owner")
|
|
163
|
+
if (
|
|
164
|
+
spec.get("interactive")
|
|
165
|
+
and not spec.get("background_launch")
|
|
166
|
+
and owner
|
|
167
|
+
and not (
|
|
168
|
+
Pool.alive(owner["pid"])
|
|
169
|
+
and process_birth(owner["pid"]) == owner.get("birth")
|
|
170
|
+
)
|
|
171
|
+
):
|
|
172
|
+
spec["foreground_owner"] = {
|
|
173
|
+
"pid": os.getpid(),
|
|
174
|
+
"birth": process_birth(os.getpid()),
|
|
175
|
+
}
|
|
176
|
+
atomic_json(spec_path, spec)
|
|
158
177
|
meta = root / (ident + ".connection.json")
|
|
159
178
|
invocation_cwd = cwd
|
|
160
179
|
reconnect = False
|
|
@@ -353,7 +372,7 @@ def restart(state, prefix):
|
|
|
353
372
|
spec = json.loads(path.read_text())
|
|
354
373
|
if not Path(spec["cwd"]).is_dir():
|
|
355
374
|
raise RouterError("The project folder no longer exists")
|
|
356
|
-
spec.update(state="starting")
|
|
375
|
+
spec.update(state="starting", background_launch=True, foreground_owner=None)
|
|
357
376
|
spec.pop("last_error", None)
|
|
358
377
|
atomic_json(path, spec)
|
|
359
378
|
process = spawn(
|
|
@@ -378,6 +397,54 @@ def restart(state, prefix):
|
|
|
378
397
|
return ident
|
|
379
398
|
|
|
380
399
|
|
|
400
|
+
def foreground_owner_gone(spec, preferences, controller, *, now=None):
|
|
401
|
+
"""Foreground jobs must not outlive a vanished launcher unless explicitly detached."""
|
|
402
|
+
if (
|
|
403
|
+
not spec.get("interactive")
|
|
404
|
+
or spec.get("background_launch")
|
|
405
|
+
or spec.get("takeover")
|
|
406
|
+
or preferences.get("background_on_disconnect")
|
|
407
|
+
or controller.client
|
|
408
|
+
):
|
|
409
|
+
return False
|
|
410
|
+
owner = spec.get("foreground_owner")
|
|
411
|
+
if owner:
|
|
412
|
+
return not (
|
|
413
|
+
Pool.alive(owner["pid"])
|
|
414
|
+
and process_birth(owner["pid"]) == owner.get("birth")
|
|
415
|
+
)
|
|
416
|
+
# Earlier jobs did not record a launcher. Only retire a definitely empty,
|
|
417
|
+
# unattached picker; retain older conversations whose ownership is uncertain.
|
|
418
|
+
return (
|
|
419
|
+
not spec.get("resume")
|
|
420
|
+
and not controller.last_turn
|
|
421
|
+
and (time.time() if now is None else now) - spec.get("created", time.time())
|
|
422
|
+
> 60
|
|
423
|
+
)
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
async def close_orphaned_foreground(controller, spec_path):
|
|
427
|
+
spec = json.loads(spec_path.read_text())
|
|
428
|
+
if not foreground_owner_gone(spec, controller.preferences, controller):
|
|
429
|
+
return
|
|
430
|
+
if (
|
|
431
|
+
controller.active_threads
|
|
432
|
+
or controller.active_items
|
|
433
|
+
or controller.server_requests
|
|
434
|
+
):
|
|
435
|
+
for thread in controller.last_turn:
|
|
436
|
+
controller.hold_for_review(thread, "terminal_closed_during_action")
|
|
437
|
+
controller.exit_state = "needs_attention"
|
|
438
|
+
if not getattr(controller, "orphan_reported", False):
|
|
439
|
+
controller.note("needs_attention", reason="terminal_closed_during_action")
|
|
440
|
+
controller.orphan_reported = True
|
|
441
|
+
return
|
|
442
|
+
controller.note("foreground_closed", reason="launcher_exited")
|
|
443
|
+
spec["state"] = controller.exit_state
|
|
444
|
+
atomic_json(spec_path, spec)
|
|
445
|
+
controller.stopping.set()
|
|
446
|
+
|
|
447
|
+
|
|
381
448
|
async def _serve(state, ident, binary):
|
|
382
449
|
from websockets.asyncio.server import serve as ws_serve
|
|
383
450
|
from router import (
|
|
@@ -456,6 +523,9 @@ async def _serve(state, ident, binary):
|
|
|
456
523
|
return new or old
|
|
457
524
|
|
|
458
525
|
controller.extra_accounts = reload_accounts
|
|
526
|
+
controller.lifecycle_check = lambda: close_orphaned_foreground(
|
|
527
|
+
controller, spec_path
|
|
528
|
+
)
|
|
459
529
|
controller.preferences = settings.load(state, cwd, spec.get("profile"))
|
|
460
530
|
controller.retry_interval = controller.preferences["retry_interval"]
|
|
461
531
|
controller.retry_attempts = controller.preferences["retry_attempts"]
|
|
@@ -513,6 +583,9 @@ async def _serve(state, ident, binary):
|
|
|
513
583
|
pool.touch(state="waiting_for_reset", next_wake=deadline)
|
|
514
584
|
await controller.wait_until(deadline, controller.user_generation)
|
|
515
585
|
if controller.stopping.is_set():
|
|
586
|
+
latest = json.loads(spec_path.read_text())
|
|
587
|
+
latest["state"] = controller.exit_state
|
|
588
|
+
atomic_json(spec_path, latest)
|
|
516
589
|
return 0
|
|
517
590
|
controller.backend_factory = factory
|
|
518
591
|
thread_id = old_thread or spec.get("resume")
|
|
@@ -688,7 +761,7 @@ async def _serve(state, ident, binary):
|
|
|
688
761
|
# have been reconciled. A frontend must not race recovery setup.
|
|
689
762
|
atomic_json(connection, meta)
|
|
690
763
|
await controller.stopping.wait()
|
|
691
|
-
spec["state"] =
|
|
764
|
+
spec["state"] = controller.exit_state
|
|
692
765
|
atomic_json(spec_path, spec)
|
|
693
766
|
return 0
|
|
694
767
|
finally:
|
package/tests/test_audit.py
CHANGED
|
@@ -45,6 +45,31 @@ class RecoveryRegressions(unittest.IsolatedAsyncioTestCase):
|
|
|
45
45
|
self.assertTrue(any(method == "turn/start" for method, _ in backend.calls))
|
|
46
46
|
pool.close()
|
|
47
47
|
|
|
48
|
+
async def test_explicit_retry_clears_a_review_hold_during_an_existing_timer(self):
|
|
49
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
50
|
+
pool = Pool(Path(folder))
|
|
51
|
+
backend = FakeBackend()
|
|
52
|
+
c = Controller(
|
|
53
|
+
[FakeAccount("one")], backend, pool=pool, report=lambda _: None
|
|
54
|
+
)
|
|
55
|
+
c.client = FakeClient()
|
|
56
|
+
c.last_turn["thread-1"] = {"threadId": "thread-1"}
|
|
57
|
+
c.recovery = asyncio.create_task(c.recover("thread-1", 0, "capacity"))
|
|
58
|
+
try:
|
|
59
|
+
while not c.episodes:
|
|
60
|
+
await asyncio.sleep(0)
|
|
61
|
+
c.hold_for_review("thread-1")
|
|
62
|
+
await c.control("retry", {"thread": "thread-1"})
|
|
63
|
+
await asyncio.wait_for(c.recovery, 1)
|
|
64
|
+
self.assertFalse(pool.intent("thread-1")["review_required"])
|
|
65
|
+
self.assertEqual(
|
|
66
|
+
sum(method == "turn/start" for method, _ in backend.calls), 1
|
|
67
|
+
)
|
|
68
|
+
finally:
|
|
69
|
+
c.recovery.cancel()
|
|
70
|
+
await asyncio.gather(c.recovery, return_exceptions=True)
|
|
71
|
+
pool.close()
|
|
72
|
+
|
|
48
73
|
async def test_picker_permission_control_applies_before_thread_selection(self):
|
|
49
74
|
c = Controller([FakeAccount("one")], FakeBackend(), report=lambda _: None)
|
|
50
75
|
c.launch_overrides = {
|
|
@@ -318,3 +343,89 @@ class AttachmentRegressions(unittest.IsolatedAsyncioTestCase):
|
|
|
318
343
|
self.assertFalse(
|
|
319
344
|
parse_arguments(["attach", "task", "--", "--takeover"]).takeover
|
|
320
345
|
)
|
|
346
|
+
|
|
347
|
+
|
|
348
|
+
class ForegroundLifecycleTests(unittest.IsolatedAsyncioTestCase):
|
|
349
|
+
async def test_closed_launcher_retires_an_empty_idle_foreground_job(self):
|
|
350
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
351
|
+
path = Path(folder) / "job.json"
|
|
352
|
+
job = {
|
|
353
|
+
"interactive": True,
|
|
354
|
+
"foreground_owner": {"pid": 99999999, "birth": "gone"},
|
|
355
|
+
"state": "starting",
|
|
356
|
+
}
|
|
357
|
+
atomic_json(path, job)
|
|
358
|
+
c = Controller([FakeAccount("one")], FakeBackend(), report=lambda _: None)
|
|
359
|
+
await service.close_orphaned_foreground(c, path)
|
|
360
|
+
self.assertTrue(c.stopping.is_set())
|
|
361
|
+
self.assertEqual(json.loads(path.read_text())["state"], "stopped")
|
|
362
|
+
|
|
363
|
+
async def test_real_launcher_background_preference_and_takeover_are_preserved(self):
|
|
364
|
+
c = Controller([FakeAccount("one")], FakeBackend(), report=lambda _: None)
|
|
365
|
+
job = {
|
|
366
|
+
"interactive": True,
|
|
367
|
+
"foreground_owner": {
|
|
368
|
+
"pid": os.getpid(),
|
|
369
|
+
"birth": process_birth(os.getpid()),
|
|
370
|
+
},
|
|
371
|
+
}
|
|
372
|
+
self.assertFalse(service.foreground_owner_gone(job, c.preferences, c))
|
|
373
|
+
dead = {**job, "foreground_owner": {"pid": 99999999, "birth": "gone"}}
|
|
374
|
+
for field in ("background_launch", "takeover"):
|
|
375
|
+
self.assertFalse(
|
|
376
|
+
service.foreground_owner_gone({**dead, field: True}, c.preferences, c)
|
|
377
|
+
)
|
|
378
|
+
self.assertFalse(
|
|
379
|
+
service.foreground_owner_gone(
|
|
380
|
+
dead, {**c.preferences, "background_on_disconnect": True}, c
|
|
381
|
+
)
|
|
382
|
+
)
|
|
383
|
+
c.client = FakeClient()
|
|
384
|
+
self.assertFalse(service.foreground_owner_gone(dead, c.preferences, c))
|
|
385
|
+
|
|
386
|
+
async def test_orphaned_active_action_requires_review_and_is_not_killed(self):
|
|
387
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
388
|
+
state = Path(folder)
|
|
389
|
+
path = state / "job.json"
|
|
390
|
+
pool = Pool(state)
|
|
391
|
+
atomic_json(
|
|
392
|
+
path,
|
|
393
|
+
{
|
|
394
|
+
"interactive": True,
|
|
395
|
+
"foreground_owner": {"pid": 99999999, "birth": "gone"},
|
|
396
|
+
},
|
|
397
|
+
)
|
|
398
|
+
c = Controller(
|
|
399
|
+
[FakeAccount("one")], FakeBackend(), pool=pool, report=lambda _: None
|
|
400
|
+
)
|
|
401
|
+
c.last_turn["thread"] = {"threadId": "thread"}
|
|
402
|
+
c.active_items.add("command")
|
|
403
|
+
await service.close_orphaned_foreground(c, path)
|
|
404
|
+
self.assertFalse(c.stopping.is_set())
|
|
405
|
+
self.assertTrue(pool.intent("thread")["review_required"])
|
|
406
|
+
c.active_items.clear()
|
|
407
|
+
await service.close_orphaned_foreground(c, path)
|
|
408
|
+
self.assertTrue(c.stopping.is_set())
|
|
409
|
+
self.assertEqual(json.loads(path.read_text())["state"], "needs_attention")
|
|
410
|
+
pool.close()
|
|
411
|
+
|
|
412
|
+
async def test_explicit_review_or_stop_clears_orphan_exit_status(self):
|
|
413
|
+
c = Controller([FakeAccount("one")], FakeBackend(), report=lambda _: None)
|
|
414
|
+
c.hold_for_review("thread")
|
|
415
|
+
c.exit_state = "needs_attention"
|
|
416
|
+
c.clear_review("thread")
|
|
417
|
+
self.assertEqual(c.exit_state, "stopped")
|
|
418
|
+
c.exit_state = "needs_attention"
|
|
419
|
+
await c.control("stop", {})
|
|
420
|
+
self.assertEqual(c.exit_state, "stopped")
|
|
421
|
+
|
|
422
|
+
def test_legacy_empty_picker_expires_but_saved_conversation_is_retained(self):
|
|
423
|
+
c = Controller([FakeAccount("one")], FakeBackend(), report=lambda _: None)
|
|
424
|
+
job = {"interactive": True, "created": 1000}
|
|
425
|
+
self.assertFalse(service.foreground_owner_gone(job, c.preferences, c, now=1005))
|
|
426
|
+
self.assertTrue(service.foreground_owner_gone(job, c.preferences, c, now=1100))
|
|
427
|
+
self.assertFalse(
|
|
428
|
+
service.foreground_owner_gone(
|
|
429
|
+
{**job, "resume": "saved"}, c.preferences, c, now=1100
|
|
430
|
+
)
|
|
431
|
+
)
|
package/tests/test_features.py
CHANGED
|
@@ -85,6 +85,31 @@ class ManagementTests(unittest.TestCase):
|
|
|
85
85
|
with self.assertRaises(ValueError):
|
|
86
86
|
prepare_worktree(["resume", "--worktree"], state, str(repo))
|
|
87
87
|
|
|
88
|
+
def test_both_displays_receive_the_same_fractional_allowance(self):
|
|
89
|
+
with tempfile.TemporaryDirectory() as folder:
|
|
90
|
+
state = Path(folder)
|
|
91
|
+
home = state / "accounts" / "one"
|
|
92
|
+
home.mkdir(parents=True)
|
|
93
|
+
(home / "enrolled").write_text("{}")
|
|
94
|
+
pool = Pool(state)
|
|
95
|
+
pool.save(
|
|
96
|
+
"one",
|
|
97
|
+
{
|
|
98
|
+
"rateLimits": {
|
|
99
|
+
"primary": {"usedPercent": 70.2},
|
|
100
|
+
"secondary": {"usedPercent": 41.5},
|
|
101
|
+
}
|
|
102
|
+
},
|
|
103
|
+
)
|
|
104
|
+
value = commands.snapshot(state)
|
|
105
|
+
account = value["accounts"][0]
|
|
106
|
+
self.assertEqual(account["short_remaining"], "29.8%")
|
|
107
|
+
self.assertEqual(account["weekly_remaining"], "58.5%")
|
|
108
|
+
text = commands.render(value)
|
|
109
|
+
self.assertIn(account["short_remaining"], text)
|
|
110
|
+
self.assertIn(account["weekly_remaining"], text)
|
|
111
|
+
pool.close()
|
|
112
|
+
|
|
88
113
|
def test_account_emails_are_displayed_and_resolve_without_reading_tokens(self):
|
|
89
114
|
with tempfile.TemporaryDirectory() as folder:
|
|
90
115
|
state = Path(folder)
|