Skip to content

Commit 31041bd

Browse files
author
pavelhov
authored
Merge pull request #1 from pavelhov/codex/multi-agent-workstation
[WRONG BRANCH] Codex/multi agent workstation
2 parents 6f88fc3 + 3374f7b commit 31041bd

151 files changed

Lines changed: 9939 additions & 1424 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎.github/workflows/release.yml‎

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -50,12 +50,24 @@ jobs:
5050
outputs:
5151
archive_name: ${{ steps.package.outputs.archive_name }}
5252
checksum_name: ${{ steps.package.outputs.checksum_name }}
53+
distribution_ready: ${{ steps.package.outputs.distribution_ready }}
5354
steps:
5455
- name: Checkout
5556
uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7
5657
with:
5758
persist-credentials: false
5859

60+
- name: Setup Bun
61+
uses: oven-sh/setup-bun@0c5077e51419868618aeaa5fe8019c62421857d6 # v2
62+
with:
63+
bun-version: 1.3.14
64+
65+
- name: Install locked dependencies
66+
run: bun install --frozen-lockfile
67+
68+
- name: Build the bundled dashboard
69+
run: bun run build:gui
70+
5971
- name: Package the macOS companion
6072
id: package
6173
env:
@@ -79,7 +91,10 @@ jobs:
7991
attach-macos:
8092
runs-on: ubuntu-latest
8193
needs: [publish, package-macos]
82-
if: ${{ inputs.dry-run != true }}
94+
# Development/ad-hoc ZIPs remain downloadable workflow artifacts, but are never
95+
# attached to a public release. The packager marks an asset distribution-ready
96+
# only after Developer ID signing, Gatekeeper assessment, and staple validation.
97+
if: ${{ inputs.dry-run != true && needs.package-macos.outputs.distribution_ready == 'true' }}
8398
timeout-minutes: 10
8499
permissions:
85100
contents: write # attach assets to the release created by publish

‎app/Sources/MenuBarCore/ActionCoordinator.swift‎

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,24 @@ public enum RestartOutcome: Equatable, Sendable {
1616
public enum ProxyControlOutcome: Equatable, Sendable {
1717
case running
1818
case stopped
19+
/// The proxy is healthy, but long-lived Codex workers still hold an older roster.
20+
case catalogUpdateReady(staleWorkerCount: Int?)
21+
case failed(String)
22+
}
23+
24+
public struct CodexCatalogApplySummary: Equatable, Sendable {
25+
public let catalogUpdated: Bool
26+
public let stoppedWorkerCount: Int
27+
28+
public init(catalogUpdated: Bool, stoppedWorkerCount: Int) {
29+
self.catalogUpdated = catalogUpdated
30+
self.stoppedWorkerCount = stoppedWorkerCount
31+
}
32+
}
33+
34+
public enum CodexCatalogApplyOutcome: Equatable, Sendable {
35+
case applied(CodexCatalogApplySummary)
36+
case incomplete(message: String, stoppedWorkerCount: Int, survivingWorkerCount: Int)
1937
case failed(String)
2038
}
2139

@@ -65,6 +83,9 @@ public actor ActionCoordinator {
6583
) async -> ProxyControlOutcome {
6684
do {
6785
let result = try await lifecycle.run(action)
86+
if result.state == .running, result.codexRestartRequired == true {
87+
return .catalogUpdateReady(staleWorkerCount: result.staleWorkerCount)
88+
}
6889
guard result.ok, result.state == expected else {
6990
return .failed(result.message)
7091
}
@@ -76,6 +97,42 @@ public actor ActionCoordinator {
7697
}
7798
}
7899

100+
/// Applies the current model catalog and restarts only Codex's catalog-caching
101+
/// workers through the fixed lifecycle bridge. The OpenCodex proxy is untouched.
102+
public func applyCodexCatalog() async -> CodexCatalogApplyOutcome {
103+
do {
104+
let result = try await lifecycle.run(.applyCodexCatalog)
105+
let stopped = result.stoppedWorkerCount ?? 0
106+
let surviving = result.survivingWorkerCount ?? 0
107+
// A stale worker may coexist with a refused or failed catalog write. The
108+
// write failure is authoritative: never describe that result as applied.
109+
if !result.ok, result.errorCode == "SYNC_FAILED" {
110+
return .failed(result.message)
111+
}
112+
if surviving > 0 || result.codexRestartRequired == true {
113+
return .incomplete(
114+
message: result.message,
115+
stoppedWorkerCount: stopped,
116+
survivingWorkerCount: surviving
117+
)
118+
}
119+
guard result.ok,
120+
result.state == .running || result.state == .stopped,
121+
let catalogUpdated = result.catalogUpdated
122+
else {
123+
return .failed(result.message)
124+
}
125+
return .applied(CodexCatalogApplySummary(
126+
catalogUpdated: catalogUpdated,
127+
stoppedWorkerCount: stopped
128+
))
129+
} catch let error as LifecycleHelperError {
130+
return .failed(error.userMessage)
131+
} catch {
132+
return .failed("OpenCodex could not apply the agent catalog update.")
133+
}
134+
}
135+
79136
/// Requests the proxy's owned drain-and-restart path and waits for a replacement
80137
/// OpenCodex identity. A 202 only means accepted; success requires a newly
81138
/// identity-validated process (or a refused interval followed by a valid process

‎app/Sources/MenuBarCore/LifecycleHelper.swift‎

Lines changed: 57 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ public enum LifecycleAction: String, Codable, Sendable {
77
case start
88
case stop
99
case restart
10+
case applyCodexCatalog
1011
}
1112

1213
public enum LifecycleState: String, Codable, Sendable {
@@ -27,6 +28,13 @@ public struct LifecycleCommandResult: Codable, Equatable, Sendable {
2728
public let port: Int?
2829
public let message: String
2930
public let errorCode: String?
31+
/// Catalog-action fields are additive so older lifecycle results remain decodable.
32+
/// The app deliberately receives counts rather than process identifiers.
33+
public let catalogUpdated: Bool?
34+
public let codexRestartRequired: Bool?
35+
public let staleWorkerCount: Int?
36+
public let stoppedWorkerCount: Int?
37+
public let survivingWorkerCount: Int?
3038

3139
public init(
3240
schemaVersion: Int = 1,
@@ -37,7 +45,12 @@ public struct LifecycleCommandResult: Codable, Equatable, Sendable {
3745
pid: Int? = nil,
3846
port: Int? = nil,
3947
message: String,
40-
errorCode: String? = nil
48+
errorCode: String? = nil,
49+
catalogUpdated: Bool? = nil,
50+
codexRestartRequired: Bool? = nil,
51+
staleWorkerCount: Int? = nil,
52+
stoppedWorkerCount: Int? = nil,
53+
survivingWorkerCount: Int? = nil
4154
) {
4255
self.schemaVersion = schemaVersion
4356
self.action = action
@@ -48,6 +61,11 @@ public struct LifecycleCommandResult: Codable, Equatable, Sendable {
4861
self.port = port
4962
self.message = message
5063
self.errorCode = errorCode
64+
self.catalogUpdated = catalogUpdated
65+
self.codexRestartRequired = codexRestartRequired
66+
self.staleWorkerCount = staleWorkerCount
67+
self.stoppedWorkerCount = stoppedWorkerCount
68+
self.survivingWorkerCount = survivingWorkerCount
5169
}
5270
}
5371

@@ -92,10 +110,19 @@ public enum LifecycleHelperDiscovery {
92110
home: URL = FileManager.default.homeDirectoryForCurrentUser,
93111
fileManager: FileManager = .default
94112
) -> LifecycleInvocation? {
113+
// The repository build is deliberately live: while developing, edits in the
114+
// checkout must take effect without rebuilding the copied Resources snapshot.
95115
if let source = sourceInvocation(bundleURL: bundleURL, fileManager: fileManager) {
96116
return source
97117
}
98118

119+
// A released companion carries the complete Bun + OpenCodex package under
120+
// Contents/Resources/runtime. Resolve it before global installs so a copied app
121+
// never accidentally controls a different checkout or npm installation.
122+
if let bundled = bundledInvocation(bundleURL: bundleURL, fileManager: fileManager) {
123+
return bundled
124+
}
125+
99126
// Release companions may be launched outside the source tree. Only inspect
100127
// fixed installation locations, then resolve an npm/bun symlink back to an
101128
// actual OpenCodex package root. PATH and version-directory scans would turn
@@ -125,6 +152,19 @@ public enum LifecycleHelperDiscovery {
125152
return nil
126153
}
127154

155+
private static func bundledInvocation(
156+
bundleURL: URL,
157+
fileManager: FileManager
158+
) -> LifecycleInvocation? {
159+
let bundle = bundleURL.resolvingSymlinksInPath()
160+
guard bundle.pathExtension == "app" else { return nil }
161+
let runtime = bundle
162+
.appendingPathComponent("Contents", isDirectory: true)
163+
.appendingPathComponent("Resources", isDirectory: true)
164+
.appendingPathComponent("runtime", isDirectory: true)
165+
return repositoryInvocation(runtime, fileManager: fileManager)
166+
}
167+
128168
private static func sourceInvocation(
129169
bundleURL: URL,
130170
fileManager: FileManager
@@ -263,7 +303,7 @@ public actor LifecycleHelper: LifecycleCommandRunning {
263303
process.arguments = invocation.prefixArguments + ["__macos-lifecycle", action.rawValue]
264304
process.standardOutput = pipe
265305
process.standardError = FileHandle.nullDevice
266-
process.environment = Self.controlledEnvironment()
306+
process.environment = Self.controlledEnvironment(for: invocation.executable)
267307
pipe.fileHandleForReading.readabilityHandler = { handle in
268308
output.append(handle.availableData)
269309
}
@@ -313,6 +353,15 @@ public actor LifecycleHelper: LifecycleCommandRunning {
313353
result.message.utf8.count <= 240,
314354
result.pid.map({ $0 > 0 }) ?? true,
315355
result.port.map({ (1...65_535).contains($0) }) ?? true,
356+
result.staleWorkerCount.map({ $0 >= 0 }) ?? true,
357+
result.stoppedWorkerCount.map({ $0 >= 0 }) ?? true,
358+
result.survivingWorkerCount.map({ $0 >= 0 }) ?? true,
359+
action != .applyCodexCatalog || !result.ok || (
360+
result.catalogUpdated != nil
361+
&& result.codexRestartRequired != nil
362+
&& result.stoppedWorkerCount != nil
363+
&& result.survivingWorkerCount != nil
364+
),
316365
(process.terminationStatus == 0) == result.ok
317366
else {
318367
continuation.resume(throwing: LifecycleHelperError.invalidResponse)
@@ -325,9 +374,14 @@ public actor LifecycleHelper: LifecycleCommandRunning {
325374

326375
/// Preserve OpenCodex/Codex configuration while removing runtime preloads and an
327376
/// attacker-controlled PATH from this privileged fixed-action bridge.
328-
private nonisolated static func controlledEnvironment() -> [String: String] {
377+
private nonisolated static func controlledEnvironment(for executable: URL) -> [String: String] {
329378
var environment = ProcessInfo.processInfo.environment
330379
environment["PATH"] = "/usr/bin:/bin:/usr/sbin:/sbin"
380+
if executable.path.contains("/Contents/Resources/runtime/") {
381+
// The app-owned runtime must never enter npm/source self-update paths.
382+
// This marker is inherited by the Bun proxy process and its management API.
383+
environment["OCX_APP_RUNTIME"] = "1"
384+
}
331385
for key in [
332386
"BUN_OPTIONS", "BUN_INSPECT", "BUN_INSPECT_CONNECT_TO",
333387
"NODE_OPTIONS", "NODE_PATH", "LD_PRELOAD",

‎app/Sources/MenuBarCoreTests/ActionSuite.swift‎

Lines changed: 129 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -197,6 +197,135 @@ enum ActionSuite {
197197
)
198198
}
199199

200+
t.test("lifecycle: a live proxy with stale Codex workers becomes update-ready") {
201+
let lifecycle = FakeLifecycleRunner(results: [
202+
LifecycleCommandResult(
203+
action: .ensure, ok: false, state: .running,
204+
changed: true, pid: 41, port: 10100,
205+
message: "Restart ChatGPT to load the routed models.",
206+
errorCode: "CODEX_RESTART_REQUIRED",
207+
codexRestartRequired: true,
208+
staleWorkerCount: 2
209+
),
210+
])
211+
let coordinator = ActionCoordinator(
212+
client: FakeActionClient(liveness: []),
213+
lifecycle: lifecycle
214+
)
215+
t.equal(
216+
sync { await coordinator.ensure() },
217+
.catalogUpdateReady(staleWorkerCount: 2)
218+
)
219+
}
220+
221+
t.test("lifecycle: restart-like error codes without the additive flag remain failures") {
222+
let lifecycle = FakeLifecycleRunner(results: [
223+
LifecycleCommandResult(
224+
action: .ensure, ok: false, state: .running,
225+
changed: false, pid: 41, port: 10100,
226+
message: "Legacy Codex restart required.",
227+
errorCode: "CODEX_RESTART_REQUIRED"
228+
),
229+
LifecycleCommandResult(
230+
action: .ensure, ok: false, state: .running,
231+
changed: false, pid: 41, port: 10100,
232+
message: "Proxy restart required.",
233+
errorCode: "PROXY_RESTART_REQUIRED"
234+
),
235+
])
236+
let coordinator = ActionCoordinator(
237+
client: FakeActionClient(liveness: []),
238+
lifecycle: lifecycle
239+
)
240+
t.equal(
241+
sync { await coordinator.ensure() },
242+
.failed("Legacy Codex restart required.")
243+
)
244+
t.equal(
245+
sync { await coordinator.ensure() },
246+
.failed("Proxy restart required.")
247+
)
248+
}
249+
250+
t.test("catalog apply: uses the fixed helper action and returns counts") {
251+
let lifecycle = FakeLifecycleRunner(results: [
252+
LifecycleCommandResult(
253+
action: .applyCodexCatalog, ok: true, state: .running,
254+
changed: true, pid: 41, port: 10100,
255+
message: "Agent catalog updated.",
256+
catalogUpdated: true,
257+
codexRestartRequired: false,
258+
stoppedWorkerCount: 2,
259+
survivingWorkerCount: 0
260+
),
261+
])
262+
let coordinator = ActionCoordinator(
263+
client: FakeActionClient(liveness: []),
264+
lifecycle: lifecycle
265+
)
266+
t.equal(
267+
sync { await coordinator.applyCodexCatalog() },
268+
.applied(CodexCatalogApplySummary(
269+
catalogUpdated: true,
270+
stoppedWorkerCount: 2
271+
))
272+
)
273+
t.equal(
274+
sync { await lifecycle.recordedActions() },
275+
[.applyCodexCatalog]
276+
)
277+
}
278+
279+
t.test("catalog apply: surviving workers remain an incomplete update") {
280+
let lifecycle = FakeLifecycleRunner(results: [
281+
LifecycleCommandResult(
282+
action: .applyCodexCatalog, ok: false, state: .running,
283+
changed: true, pid: 41, port: 10100,
284+
message: "One Codex worker is still running.",
285+
catalogUpdated: true,
286+
codexRestartRequired: true,
287+
stoppedWorkerCount: 1,
288+
survivingWorkerCount: 1
289+
),
290+
])
291+
let coordinator = ActionCoordinator(
292+
client: FakeActionClient(liveness: []),
293+
lifecycle: lifecycle
294+
)
295+
t.equal(
296+
sync { await coordinator.applyCodexCatalog() },
297+
.incomplete(
298+
message: "One Codex worker is still running.",
299+
stoppedWorkerCount: 1,
300+
survivingWorkerCount: 1
301+
)
302+
)
303+
}
304+
305+
t.test("catalog apply: a sync failure wins over stale surviving workers") {
306+
let lifecycle = FakeLifecycleRunner(results: [
307+
LifecycleCommandResult(
308+
action: .applyCodexCatalog, ok: false, state: .running,
309+
changed: false, pid: 41, port: 10100,
310+
message: "Agent catalog update did not complete.",
311+
errorCode: "SYNC_FAILED",
312+
catalogUpdated: false,
313+
codexRestartRequired: true,
314+
staleWorkerCount: 2,
315+
stoppedWorkerCount: 0,
316+
survivingWorkerCount: 2
317+
),
318+
])
319+
let coordinator = ActionCoordinator(
320+
client: FakeActionClient(liveness: []),
321+
lifecycle: lifecycle
322+
)
323+
t.equal(
324+
sync { await coordinator.applyCodexCatalog() },
325+
.failed("Agent catalog update did not complete.")
326+
)
327+
}
328+
200329
}
201330

202331
private static func sync<T>(_ operation: @escaping () async -> T) -> T {

0 commit comments

Comments
 (0)