codexteer 0.18.0 → 0.18.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/CHANGELOG.md +18 -6
- package/README.md +6 -2
- package/docs/distribution.md +12 -7
- package/package.json +1 -1
- package/src/cli.mjs +3 -7
- package/src/cloud-cli.mjs +3 -4
- package/src/store.mjs +19 -10
- package/src/supervision.mjs +58 -16
package/CHANGELOG.md
CHANGED
|
@@ -1,6 +1,18 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
公開日は日本時間です。
|
|
4
|
+
|
|
5
|
+
## 0.18.2 — 2026-11-01公開
|
|
6
|
+
|
|
7
|
+
- 停止済みの監督IDが、別の監督への交代後に古い本文から再登録できる問題を修正しました。停止履歴を監督記録に保持し、登録前のdoctor・再登録・観測・送信で`SUPERVISOR_STOPPED`を返します。旧形式の停止済み記録も、交代時に履歴へ引き継ぎます。保存済み旧CLIを使う監督には、更新後のCLIでプロンプトを生成し直してください。
|
|
8
|
+
- 同じ監督IDの別タスクへの登録を拒否します。既存データでIDが重複している場合も`supervise list --json`は全件を返し、`session_conflict.thread_ids`と、他タスクの観測記録を除外した項目の`observation_error`で衝突を示します。各タスクの`supervise stop`で停止できます。
|
|
9
|
+
|
|
10
|
+
## 0.18.1 — 2026-10-31
|
|
11
|
+
|
|
12
|
+
- ローカルロックの保持者と回収者が続けてクラッシュした後、`RECORD_BUSY`から自動回復できなくなる問題を修正しました。回収処理にもPIDと世代トークンを持つロックを使い、生きた保持者・回収者の排他を維持します。
|
|
13
|
+
- 更新前に、同じ`CODEX_HOME`を使う旧版CLIの操作と、保存済み旧CLIで動く監督を終了してください。旧版と修正版の回収方式を混在させることはできません。残った旧`.recover`ディレクトリは修正版の動作を妨げません。更新後に監督を起動し直すか、プロンプトを生成し直してください。
|
|
14
|
+
|
|
15
|
+
## 0.18.0 — 2026-10-29公開
|
|
4
16
|
|
|
5
17
|
- `thread repair <THREAD>`で古いターンの終了記録欠落を検出します。明示的な`--apply`はCodexの完全終了・検証済みバージョンを必須とし、バックアップ後に対象IDの中断記録だけを追記・照合します。既存の会話やDBは書き換えず、最新の未完了ターンは修復しません。
|
|
6
18
|
- macOS向けに`desktop update`と`desktop update-status`を追加しました。独立したworkerが公式更新UI(英語・日本語)を操作し、新ビルドと再起動を確認してから共有wrapperを再適用・検証します。外部NodeとターミナルのAccessibility・Automation権限が必要です。
|
|
@@ -13,21 +25,21 @@
|
|
|
13
25
|
- POSIXパスの大文字小文字を保持し、CLIの宛先はオプション値ではなく位置引数から選択します。監督の制御は登録時のtransportと一致する場合だけ受け付けます。
|
|
14
26
|
- リモート作成の再照合失敗時は古い認定フラグを除去してreceiptへ記録します。実行RPCの内部エラーでは実行済みの可能性を保持し、resource leaseを解放しません。
|
|
15
27
|
|
|
16
|
-
## 0.18.0 — 未公開
|
|
17
|
-
|
|
18
28
|
- Cloud監督の登録前診断と、初回配送の受付不明を共通履歴から照合・回復できるよう修正しました。本文・client IDが一致する保存証拠だけを採用し、自動再送は行いません。
|
|
19
29
|
|
|
20
30
|
- Desktop Cloudの所属確認をdoctorへ追加し、プロジェクト一覧と環境一覧を分離しました。署名付きのthread assignは実環境・nonce・署名・保存結果を照合します。そのDesktop実行環境への結合が必要で、通常のCloud環境の拒否を成功扱いしたり別環境へ置き換えたりしません。作成後のタイトル設定・初回配送失敗時にも通常表示へ作成済みIDと復旧情報を残します。
|
|
21
31
|
|
|
22
32
|
- Desktop Cloudの環境構成一覧・新規作成・継続送信・共通監視を追加しました。初回送信にも選択済み環境と遅延起動指定を保持します。環境選択とDesktopプロジェクト所属を区別し、所属登録が確認できない`--project`作成は事前拒否します。受付不明の自動再送は行いません。
|
|
23
33
|
|
|
24
|
-
- Cloudの重複拒否で前回の試行IDを今回の配送履歴へ結び付けないよう修正しました。回復用IDを分離し、history check
|
|
34
|
+
- Cloudの重複拒否で前回の試行IDを今回の配送履歴へ結び付けないよう修正しました。回復用IDを分離し、history checkは今回の本文・親ターンも照合して、拒否した指示の誤有効化を防ぎます。作成用--env別名を削除し、環境指定は--projectに統一しました。新規作成の本文がCloud ID/URLで始まっても、それを宛先と誤判定しません。
|
|
25
35
|
|
|
26
36
|
- 共通Cloud観測で公開commentaryの途中経過を保持し、途中経過だけの更新でもMonitorへ通知します。reasoning等の非公開内容は除外し、`--backend app-server`を宛先より前に置く場合のCloud振り分けも修正しました。
|
|
27
37
|
|
|
28
38
|
- Cloudタスクも共通の`supervise`・`read/watch`・`send`・`history/instructions/findings`で扱えます。監督の登録・pause/resume/stop・保存済みCLI・Monitorのdigestと復帰待ちを既存基盤へ接続し、実行中の指定ターンの中断と同一タスク内での継続、配送照合を追加しました。旧Desktop CLIとの記録互換性を保つCloud専用監督名前空間を使います。
|
|
29
39
|
|
|
30
|
-
- Cloud環境・タスクの一覧と新規作成は`--connection cloud projects list`・`threads list`・`thread create`で扱います。作成試行は`thread receipt`、配送照合・予約回復は`history show/check --attempt`、試行を選ぶ差分取得は`diff --attempt
|
|
40
|
+
- Cloud環境・タスクの一覧と新規作成は`--connection cloud projects list`・`threads list`・`thread create`で扱います。作成試行は`thread receipt`、配送照合・予約回復は`history show/check --attempt`、試行を選ぶ差分取得は`diff --attempt`に統一しました。重複サブコマンドと専用のカーソル・watch処理を削除し、結果不明時の自動再送は行いません。公式CLIの診断は私有一時領域で処理し、ローカル差分を暗黙に送信・適用しません。
|
|
41
|
+
|
|
42
|
+
## 0.17.0 — 2026-10-15公開
|
|
31
43
|
|
|
32
44
|
- `projects list`と`thread create --project`を追加しました。プロジェクト名と初回指示だけで作成でき、既存チャット・`CODEX_THREAD_ID`・中継AIは不要です。App Serverで作成・Desktop購読・初回配送を行い、新規チャット自身の実在IDからnative RPCで所属登録し、全フォルダと初回指示を照合します。明示的な`--context-thread`は任意で、従来のnative直接作成を使います。配送不明・部分成功を保持し、自動再作成・再送を行いません。
|
|
33
45
|
|
|
@@ -41,7 +53,7 @@
|
|
|
41
53
|
- `--settle-ms`と`--max-hold-ms`で待ち時間を調整できます。初回の読み取りは基準として扱い、実行中コマンドの出力だけの更新ではsettleを延長しません。保留分は接続断・終了時にも排出し、ページ途中の場合だけ`has_more:true`を保持します。200件/64KBはページ読了後の排出目安です。
|
|
42
54
|
- 監督プロンプトを方針先頭の8節へ整理し、既定方針に設計上の責務と作業の焦点を追加します。設計上の責務では、事実・計算・判定を設計がどの層やシステムの担当と定めているかを先に確認し、別の層での検算や再計算など、同じ事実の持ち主が二つになる実装を観測します。基準は複数のリポジトリにまたがる設計の全体とし、分担が示されていない場合は推測せずユーザーに確認します。開始時の`help monitor`の重複読み取りをなくし、通知の読み方・無操作時の応答・Monitor期限切れ・議論の送信を明示します。生成する監視コマンドは`--notify digest`を明示します。
|
|
43
55
|
|
|
44
|
-
## 0.15.1 —
|
|
56
|
+
## 0.15.1 — 2026-09-13公開
|
|
45
57
|
|
|
46
58
|
- ページ履歴に対応するタスクへの`send`・`send --new-turn`は、本文を含まないターン状態を分割取得して送信前に確認します。長いタスクの全履歴が64 MiBを超え、観測はできても送信準備だけ失敗する問題を修正しました。`--based-on`等の安全確認は維持します。
|
|
47
59
|
- 64 MiBの受信上限を超えた場合は`PAYLOAD_TOO_LARGE`を返します。上限は維持し、送信前の`not_sent`と受付未確認の`unknown`を区別します。
|
package/README.md
CHANGED
|
@@ -18,7 +18,7 @@ Desktop操作にはmacOS、Node.js 20以降とnpm、`/Applications/ChatGPT.app`
|
|
|
18
18
|
|
|
19
19
|
リポジトリのcloneやグローバルインストールなしで実行できます。npmパッケージ名とCLI名は`codexteer`です。
|
|
20
20
|
|
|
21
|
-
npm
|
|
21
|
+
npm公開済みの最新版は[`0.18.0`](https://www.npmjs.com/package/codexteer/v/0.18.0)です。リモート操作・Cloud対応・Desktop更新・オフライン履歴修復を含みます。変更点と各機能の条件は[CHANGELOG](CHANGELOG.md)を参照してください。
|
|
22
22
|
|
|
23
23
|
```bash
|
|
24
24
|
npx -y codexteer --version
|
|
@@ -36,7 +36,7 @@ npx -y codexteer doctor --json
|
|
|
36
36
|
<details>
|
|
37
37
|
<summary>特定のバージョンで実行する場合(任意)</summary>
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
不具合の切り分けや、同じ版で監督を開始し直したい場合は、パッケージ名に`@バージョン`を付けられます。公開済みの`0.18.0`を選ぶ場合は、次のように指定します。
|
|
40
40
|
|
|
41
41
|
```bash
|
|
42
42
|
npx -y codexteer@0.18.0 supervise <thread-id>
|
|
@@ -253,6 +253,10 @@ codexteer supervise stop <thread-id>
|
|
|
253
253
|
|
|
254
254
|
直接起動はエージェントを起動する前に登録し、終了時に停止を記録します。本文だけの生成では未登録で、貼り付け先が本文中の`supervise register`を実行して開始します。一つのタスクに一つの監督を登録でき、競合は`SUPERVISOR_CONFLICT`になります。異常終了などで残った登録は、`status`で確認して明示的に`stop`してから開始し直してください。停止済みIDや古い本文で再開はできません。同じ本文の再登録で一時停止が解除されることもありません。
|
|
255
255
|
|
|
256
|
+
停止済みIDは交代後も監督記録の履歴に保持し、登録前の`doctor`・再登録・観測・送信を`SUPERVISOR_STOPPED`で拒否します。更新前の停止済み記録も、交代時に履歴へ引き継ぎます。更新前にすでに上書きされて失われた停止履歴は復元できません。保存済み旧CLIには修正が反映されないため、更新後のCLIでプロンプトを生成し直してください。
|
|
257
|
+
|
|
258
|
+
同じ`CODEX_HOME`・接続先ホストの中では、監督IDを別タスクへ登録できません(`SUPERVISOR_CONFLICT`。停止済みなら`SUPERVISOR_STOPPED`)。旧版で同じIDを複数タスクに登録したデータも、`supervise list --json`は全件を返し、該当項目の`session_conflict.thread_ids`に衝突先を示します。他タスクの観測記録は`observation: null`と`observation_error`で明示し、そのタスクの観測として扱いません。重複したIDでの観測・送信は拒否されます。ユーザーの端末から各タスクを`supervise stop`し、新しいIDの監督を開始してください。
|
|
259
|
+
|
|
256
260
|
送信中の`pause`/`stop`は`RECORD_BUSY`で失敗します。配送結果を確認して制御を再実行し、成功するまでは停止したと扱わないでください。監督役が自分で`resume`したり、生成コマンドから`--supervisor`を外して回避したりしないよう、必須テンプレートにも指示します。
|
|
257
261
|
|
|
258
262
|
この制御は新しい監督ID付きコマンドに適用します。以前の版で開始した監督、IDなしの手動送信、同じOSユーザーが実行する任意のコードを隔離する機能ではありません。監督IDは秘密情報ではなく、古いセッションや操作の競合を検出する識別子です。
|
package/docs/distribution.md
CHANGED
|
@@ -1,11 +1,16 @@
|
|
|
1
1
|
# 配布と永続実行
|
|
2
2
|
|
|
3
3
|
パッケージ名は `codexteer`、CLI名は `codexteer`、ライセンスはMITです。
|
|
4
|
-
旧npmパッケージは`@vinhphatfsg/codex-steer
|
|
4
|
+
旧npmパッケージは`@vinhphatfsg/codex-steer`です。リモート操作・Cloud対応・Desktop更新・オフライン履歴修復を含む[`codexteer@0.18.0`](https://www.npmjs.com/package/codexteer/v/0.18.0)は2026-10-29にnpmへ公開済みです。GitHubのPR作成・マージはnpm公開を行う操作ではありません。次の版を公開する際も`npm whoami`でnpm側のユーザーを確認してください。
|
|
5
|
+
|
|
6
|
+
`0.18.1`は、保持者と回収者の連続クラッシュでローカルロックが永久に`RECORD_BUSY`になる問題を修正します。更新前に、同じ`CODEX_HOME`を使う旧版CLIの操作と、保存済み旧CLIで動く監督を終了してください。旧版と修正版の回収方式の混在は非対応です。旧`.recover`ディレクトリは修正版では無視されます。監督は更新後のCLIで起動し直すか、プロンプトを生成し直してください。
|
|
7
|
+
|
|
8
|
+
`0.18.2`は、監督が交代した後も停止済みIDを履歴に保持し、古い本文からの再登録・観測・送信を`SUPERVISOR_STOPPED`で拒否します。登録前の`doctor`にも同じ判定を適用します。同じIDの別タスクへの登録を拒否し、旧版で重複したIDを持つ記録も`supervise list --json`で全件と衝突先を返します。該当タスクはそれぞれ`supervise stop`で停止できます。更新前の停止済み記録は交代時に引き継ぎますが、すでに上書きされて失われた停止履歴は復元できません。保存済み旧CLIには修正が反映されないため、更新後のCLIで監督を起動し直すか、プロンプトを生成し直してください。runtime protocolとcapability契約はv1を維持し、Desktopの再起動は必要ありません。
|
|
9
|
+
|
|
5
10
|
`package.json` は `private` を持たず、`publishConfig` でnpm公式レジストリへのpublic公開を指定しています。公開可能な設定であることと、実際に公開済みであることは別です。
|
|
6
11
|
|
|
7
12
|
公開後の基本的な実行形式は次のとおりです。通常はバージョン指定なしで使えます。監督開始後は保存したCLIを使い続けるため、監督中の一貫性を保つために版を明示する必要はありません。起動側と操作側の製品バージョンも、必要な通信仕様が対応していれば異なっていても使えます。
|
|
8
|
-
以下の監督機能は公開済みの0.15.0から利用できます。0.16.0
|
|
13
|
+
以下の監督機能は公開済みの0.15.0から利用できます。0.16.0では通知集約と監督プロンプトを改善し、0.17.0でDesktopのチャット作成、0.18.0でリモート操作・Cloud対応などを追加しました。いずれもnpmから利用できます。`-y` はnpmの取得確認を省略します。
|
|
9
14
|
|
|
10
15
|
```text
|
|
11
16
|
npx -y codexteer desktop start
|
|
@@ -179,7 +184,7 @@ npxから模擬Claudeと模擬Codexを起動して保存したCLIへの参照を
|
|
|
179
184
|
|
|
180
185
|
レビュー・検証を終えた版を、npm側の公開権限を持つアカウントで公開します。手動公開には2FAを有効にしたアカウントを使ってください。
|
|
181
186
|
|
|
182
|
-
|
|
187
|
+
公開済みのパッケージ名とバージョンの組み合わせは再公開できません。`codexteer@0.18.0`は公開済みのため、次の変更をnpmへ配布する際は新しい版番号を使います。削除しても同じ名前・版番号の組み合わせを再利用できません。[npm publishの仕様](https://docs.npmjs.com/cli/v11/commands/npm-publish/)
|
|
183
188
|
|
|
184
189
|
```bash
|
|
185
190
|
npm login
|
|
@@ -188,7 +193,7 @@ npm run test:package
|
|
|
188
193
|
npm publish --access public
|
|
189
194
|
```
|
|
190
195
|
|
|
191
|
-
|
|
196
|
+
公開後は、公開した版と起動コマンドの登録を確認します。公開済みの0.18.0は次のコマンドで確認できます。
|
|
192
197
|
|
|
193
198
|
```bash
|
|
194
199
|
npm view codexteer@0.18.0 version bin --json
|
|
@@ -218,10 +223,10 @@ npx -y codexteer@0.18.0 --version
|
|
|
218
223
|
|
|
219
224
|
開発用の`npm run test:connection`は、Desktop同梱CLIを一時プロファイルのUnixソケットで起動し、ローカル偽モデルで作った合成履歴の読み取りを確認します。実Desktopへの接続・設定変更や外部モデル利用は行いません。`npm run test:protocol`は既存wrapper・偽Desktopとの送信と承認経路、`npm run test:package`は実tarball・オフラインnpx・偽Claude/Codexの起動と、別CODEX_HOME・キャッシュ削除後の観測/送信・pause/resume・重複拒否を確認します。これらの隔離検証から実Desktopの表示や直接接続での承認往復まで保証するものではありません。
|
|
220
225
|
|
|
221
|
-
## 0.18.0
|
|
226
|
+
## 0.18.0の公開結果
|
|
222
227
|
|
|
223
|
-
|
|
228
|
+
0.18.0公開時の`package.json`と`package-lock.json`の版、およびnpm registryの`latest`は0.18.0でした。公開tarballが事前検証したファイルと同一であることを確認しました。0.18.0にはリモート操作・Cloud対応・Desktop更新・オフライン履歴修復を含みます。`projects list`と`thread create --project`によるDesktopのチャット作成は0.17.0から利用できます。既存のruntime protocolとcapability契約はv1を維持します。
|
|
224
229
|
|
|
225
|
-
公開前に構文・全回帰・tarballとoffline install・npm/npx・publish dry-run
|
|
230
|
+
公開前に構文・全回帰・tarballとoffline install・npm/npx・publish dry-runを検証しました。公開対象119ファイルの内部情報・シークレット検査と依存脆弱性監査で検出は0件でした。公開後はregistryから取得したtarballのSHA-256一致と、隔離キャッシュからのCLI起動を確認しました。次の版でも検証済みtarballを固定して公開してください。PRのReady化やバージョン更新だけではnpmへ公開されません。
|
|
226
231
|
|
|
227
232
|
検証結果の要約は`remote-cli-validation.json`を参照してください。実環境のホスト・プロジェクト・チャット識別子、ローカルパス、診断ログは公開用の配布物へ含めません。npm公開・Gitタグ作成はPR更新とは別の操作です。
|
package/package.json
CHANGED
package/src/cli.mjs
CHANGED
|
@@ -15,7 +15,7 @@ import { acquireResource, resourceStatus, renewResource, releaseResource, listRe
|
|
|
15
15
|
import { playSendSound } from "./sound.mjs";
|
|
16
16
|
import { prepareSupervisorPrompt, superviseAgent } from "./supervise.mjs";
|
|
17
17
|
import { prepareOrchestrationPrompt, orchestrateAgent } from "./orchestrate.mjs";
|
|
18
|
-
import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
|
|
18
|
+
import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, assertSupervisorRegistration, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
|
|
19
19
|
import { createFinding, findingStatus, listFindings, resolveFinding, dismissFinding, reopenFinding, evaluateFinding, findingStats } from "./findings.mjs";
|
|
20
20
|
import { connectionMode, observationConnection, desktopReadOnly } from "./connection.mjs";
|
|
21
21
|
import { repairTurns } from "./turn-repair.mjs";
|
|
@@ -141,7 +141,7 @@ function printThreads(threads) {
|
|
|
141
141
|
function printSupervisor(data) {
|
|
142
142
|
if (Array.isArray(data)) {
|
|
143
143
|
if (!data.length) console.log("No supervisor sessions registered.");
|
|
144
|
-
for (const entry of data) console.log(`${entry.thread_id} ${entry.state} ${entry.owner} ${entry.connection} observed:${entry.observation?.last_observed_at ?? "none"}`);
|
|
144
|
+
for (const entry of data) console.log(`${entry.thread_id} ${entry.state} ${entry.owner} ${entry.connection} observed:${entry.observation?.last_observed_at ?? "none"}${entry.session_conflict ? ` session-conflict:${entry.session_conflict.thread_ids.join(",")}` : ""}${entry.observation_error ? ` observation-error:${entry.observation_error.code}` : ""}`);
|
|
145
145
|
return;
|
|
146
146
|
}
|
|
147
147
|
console.log(`${data.thread_id} ${data.state}${data.owner ? ` ${data.owner} ${data.connection}` : ""}`);
|
|
@@ -459,11 +459,7 @@ async function mainOnTarget(argv) {
|
|
|
459
459
|
const target = takeOption(args, "--thread", undefined);
|
|
460
460
|
if (target !== undefined && backend !== "app-server") throw new Error("doctor --thread requires --backend app-server.");
|
|
461
461
|
const threadId = target === undefined ? undefined : normalizeThreadId(target);
|
|
462
|
-
if (supervisor && threadId) {
|
|
463
|
-
const record = await getSupervisor(threadId);
|
|
464
|
-
if (record && record.session_id !== supervisor && record.state !== "stopped") throw Object.assign(new Error("Another supervisor is registered for this task."), { code: "SUPERVISOR_CONFLICT" });
|
|
465
|
-
if (record?.session_id === supervisor) await assertSupervisor(threadId, supervisor, { connection });
|
|
466
|
-
}
|
|
462
|
+
if (supervisor && threadId) await assertSupervisorRegistration(threadId, supervisor, { connection });
|
|
467
463
|
if (args.length) throw new Error(`Unexpected argument: ${args[0]}`);
|
|
468
464
|
if (targetHost() !== "local" && backend === "ui") throw remoteReadOnly();
|
|
469
465
|
const report = backend === "ui" ? desktopDoctor() : await appServerDoctor({ threadId, ...connectionDependencies });
|
package/src/cloud-cli.mjs
CHANGED
|
@@ -10,7 +10,7 @@ import { observeThread, printObservation } from "./observe.mjs";
|
|
|
10
10
|
import { monitorCommand } from "./monitor.mjs";
|
|
11
11
|
import { notificationOptions } from "./notify.mjs";
|
|
12
12
|
import { prepareSupervisorPrompt, superviseAgent } from "./supervise.mjs";
|
|
13
|
-
import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
|
|
13
|
+
import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, assertSupervisorRegistration, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
|
|
14
14
|
import { createFinding, findingStatus, listFindings, resolveFinding, dismissFinding, reopenFinding, evaluateFinding, findingStats } from "./findings.mjs";
|
|
15
15
|
import { sendTrackedMessage, listMessages, getMessage, messageSummary, reconcileMessages, markMessage, listInstructions } from "./journal.mjs";
|
|
16
16
|
import { createCloudTask, cloudTaskDiff, listCloudTasks, cloudCreationReceipt } from "./cloud.mjs";
|
|
@@ -84,9 +84,8 @@ export async function mainCloud(argv) {
|
|
|
84
84
|
const bound = async (target, { allowUnregistered = false } = {}) => {
|
|
85
85
|
const id = targetId(target);
|
|
86
86
|
if (supervisor) {
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
else if (record && record.state !== "stopped") throw failure("Another supervisor owns this cloud task.", "SUPERVISOR_CONFLICT");
|
|
87
|
+
if (allowUnregistered) await assertSupervisorRegistration(id, supervisor, { connection });
|
|
88
|
+
else await assertSupervisor(id, supervisor, { connection });
|
|
90
89
|
}
|
|
91
90
|
return id;
|
|
92
91
|
};
|
package/src/store.mjs
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { lstat, mkdir, readFile, writeFile, rename, rm, readdir, link } from "node:fs/promises";
|
|
2
|
-
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
3
3
|
import path from "node:path";
|
|
4
4
|
import { codexHome, isAlive } from "./runtime.mjs";
|
|
5
5
|
import { hostNamespace, hostFields } from "./target.mjs";
|
|
@@ -51,22 +51,31 @@ export async function listRecords(bucket, options = {}) {
|
|
|
51
51
|
}
|
|
52
52
|
|
|
53
53
|
export async function withStoreLock(key, fn, { home = codexHome() } = {}) {
|
|
54
|
-
|
|
55
|
-
|
|
54
|
+
return withRecordLock("locks", key, fn, { home });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
async function withRecordLock(bucket, key, fn, { home }) {
|
|
58
|
+
await directory(bucket, home, true);
|
|
59
|
+
const file = storePath(bucket, key, home), token = randomUUID(), temp = `${file}.${token}.tmp`;
|
|
56
60
|
await writeFile(temp, JSON.stringify({ token, pid: process.pid }), { mode: 0o600, flag: "wx" });
|
|
57
61
|
let acquired = false;
|
|
58
62
|
let outcome;
|
|
59
63
|
try {
|
|
60
64
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
61
65
|
try { await link(temp, file); acquired = true; break; } catch (e) { if (e.code !== "EEXIST") throw e; }
|
|
62
|
-
const old = await readRecord(
|
|
66
|
+
const old = await readRecord(bucket, key, { home });
|
|
63
67
|
if (old && !isAlive(old.pid)) {
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
+
// Serialize deletion of this generation, including recovery of a crashed
|
|
69
|
+
// recoverer. Each nested lock has an atomically published PID + token.
|
|
70
|
+
// A finite chain of dead owners ends at an absent recovery lock.
|
|
71
|
+
// Legacy .recover directories have no owner and are unsupported/inert.
|
|
72
|
+
// Stop all old-version operations (including saved supervisor CLIs)
|
|
73
|
+
// sharing this CODEX_HOME before using this recovery protocol.
|
|
74
|
+
const recoveryKey = createHash("sha256").update(String(old.token)).digest("hex");
|
|
75
|
+
await withRecordLock("lock-recovery", recoveryKey, async () => {
|
|
76
|
+
const current = await readRecord(bucket, key, { home });
|
|
68
77
|
if (current?.token === old.token && !isAlive(current.pid)) await rm(file);
|
|
69
|
-
}
|
|
78
|
+
}, { home });
|
|
70
79
|
} else break;
|
|
71
80
|
}
|
|
72
81
|
if (!acquired) throw Object.assign(new Error("Another operation owns this local record. Retry after it finishes."), { code: "RECORD_BUSY" });
|
|
@@ -74,7 +83,7 @@ export async function withStoreLock(key, fn, { home = codexHome() } = {}) {
|
|
|
74
83
|
return outcome;
|
|
75
84
|
} finally {
|
|
76
85
|
try {
|
|
77
|
-
if (acquired && (await readRecord(
|
|
86
|
+
if (acquired && (await readRecord(bucket, key, { home }))?.token === token) await rm(file);
|
|
78
87
|
await rm(temp, { force: true });
|
|
79
88
|
} catch { if (outcome && typeof outcome === "object") outcome.state_cleanup_required = true; }
|
|
80
89
|
}
|
package/src/supervision.mjs
CHANGED
|
@@ -21,12 +21,35 @@ export function supervisorId(value) {
|
|
|
21
21
|
return value.toLowerCase();
|
|
22
22
|
}
|
|
23
23
|
|
|
24
|
+
// Keep retired generations in the same atomic record as the current owner.
|
|
25
|
+
// A legacy stopped record contributes its ID even before its first rewrite.
|
|
26
|
+
function stoppedSessionIds(record) {
|
|
27
|
+
return [...new Set([...(record?.stopped_session_ids ?? []), ...(record?.state === "stopped" ? [record.session_id] : [])])];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function assertNotStopped(record, sessionId) {
|
|
31
|
+
if (stoppedSessionIds(record).includes(sessionId)) throw failure("This supervisor session has stopped. Start a new supervisor when needed.", "SUPERVISOR_STOPPED");
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
async function supervisorRecords(storage) {
|
|
35
|
+
return (await Promise.all([listRecords("supervisors", storage), listRecords("cloud-supervisors", storage)])).flat().filter(Boolean);
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function assertSessionBinding(threadId, sessionId, storage) {
|
|
39
|
+
const others = (await supervisorRecords(storage)).filter(record => record.thread_id !== threadId);
|
|
40
|
+
// A stopped ID stays retired even when a caller changes its target task.
|
|
41
|
+
for (const record of others) assertNotStopped(record, sessionId);
|
|
42
|
+
if (others.some(record => record.session_id === sessionId)) throw failure("This supervisor session is already registered for another task. Start a new supervisor for this task.", "SUPERVISOR_CONFLICT");
|
|
43
|
+
}
|
|
44
|
+
|
|
24
45
|
export async function getSupervisor(threadInput, storage = {}) {
|
|
25
46
|
const threadId = normalizeTaskId(threadInput), record = await readRecord(supervisorBucket(threadId), threadId, storage);
|
|
26
47
|
if (!record) return null;
|
|
27
48
|
if (record.schema !== 1 || record.thread_id !== threadId || !states.has(record.state)
|
|
28
49
|
|| !["shared", "desktop", "cloud", "desktop-cloud"].includes(record.connection)
|
|
29
50
|
|| supervisorId(record.session_id) !== record.session_id || typeof record.owner !== "string"
|
|
51
|
+
|| (record.stopped_session_ids !== undefined && (!Array.isArray(record.stopped_session_ids)
|
|
52
|
+
|| record.stopped_session_ids.some(id => supervisorId(id) !== id)))
|
|
30
53
|
|| (record.launcher_pid !== null && (!Number.isSafeInteger(record.launcher_pid) || record.launcher_pid <= 0))) {
|
|
31
54
|
throw failure("Invalid supervisor state. Inspect the local record before continuing.", "SUPERVISOR_INVALID");
|
|
32
55
|
}
|
|
@@ -34,35 +57,45 @@ export async function getSupervisor(threadInput, storage = {}) {
|
|
|
34
57
|
}
|
|
35
58
|
|
|
36
59
|
export async function assertSupervisor(threadInput, sessionInput, { sending = false, connection, ...storage } = {}) {
|
|
37
|
-
const sessionId = supervisorId(sessionInput), record = await getSupervisor(
|
|
60
|
+
const threadId = normalizeTaskId(threadInput), sessionId = supervisorId(sessionInput), record = await getSupervisor(threadId, storage);
|
|
61
|
+
assertNotStopped(record, sessionId);
|
|
62
|
+
await assertSessionBinding(threadId, sessionId, storage);
|
|
38
63
|
if (!record) throw failure("Register this supervisor session before observing or sending.", "SUPERVISOR_NOT_REGISTERED");
|
|
39
64
|
if (record.session_id !== sessionId) throw failure("Another supervisor session owns this task. This command belongs to an older or different session.", "SUPERVISOR_MISMATCH");
|
|
40
65
|
if (connection !== undefined && record.connection !== connection) throw failure("The supervisor is registered for another connection. Keep the original command intact.", "SUPERVISOR_MISMATCH");
|
|
41
66
|
if (targetHost() !== "local" && (record.remote_transport ?? "native") !== remoteTransport()) throw failure("The supervisor belongs to a different remote transport. Keep the generated route intact.", "SUPERVISOR_MISMATCH");
|
|
42
|
-
if (record.state === "stopped") throw failure("This supervisor session has stopped. Start a new supervisor when needed.", "SUPERVISOR_STOPPED");
|
|
43
67
|
if (launcherAlive(record) === false) throw failure("The supervisor launcher exited. Stop the stale session before starting another supervisor.", "SUPERVISOR_ORPHANED");
|
|
44
68
|
if (sending && record.state === "paused") throw failure("Automatic intervention is paused. Observation can continue; only the user should resume intervention.", "SUPERVISOR_PAUSED");
|
|
45
69
|
return record;
|
|
46
70
|
}
|
|
47
71
|
|
|
72
|
+
// Read-only bootstrap check shared by doctor and registration. Registration
|
|
73
|
+
// repeats it under the task lock; a successful doctor does not reserve an ID.
|
|
74
|
+
export async function assertSupervisorRegistration(threadInput, sessionInput, { connection, ...storage } = {}) {
|
|
75
|
+
const threadId = normalizeTaskId(threadInput), sessionId = supervisorId(sessionInput), current = await getSupervisor(threadId, storage);
|
|
76
|
+
assertNotStopped(current, sessionId);
|
|
77
|
+
await assertSessionBinding(threadId, sessionId, storage);
|
|
78
|
+
if (current?.session_id === sessionId) return assertSupervisor(threadInput, sessionId, { connection, ...storage });
|
|
79
|
+
if (current && current.state !== "stopped") throw failure("A supervisor already owns this task. Use supervise status, then stop it explicitly before replacing it.", "SUPERVISOR_CONFLICT");
|
|
80
|
+
return current;
|
|
81
|
+
}
|
|
82
|
+
|
|
48
83
|
export async function registerSupervisor(threadInput, sessionInput, { owner = "external", launcherPid = null, connection = "shared", ...storage } = {}) {
|
|
49
84
|
const threadId = normalizeTaskId(threadInput), sessionId = supervisorId(sessionInput);
|
|
50
85
|
if (typeof owner !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,79}$/.test(owner)) throw failure("Supervisor owner must be a short agent name.", "SUPERVISOR_INVALID");
|
|
51
86
|
if (launcherPid !== null && (!Number.isSafeInteger(launcherPid) || launcherPid <= 0)) throw failure("Invalid supervisor launcher PID.", "SUPERVISOR_INVALID");
|
|
52
87
|
if (!["shared", "desktop", "cloud", "desktop-cloud"].includes(connection)) throw failure("Invalid supervisor connection.", "SUPERVISOR_INVALID");
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
if (current && current.state !== "stopped") throw failure("A supervisor already owns this task. Use supervise status, then stop it explicitly before replacing it.", "SUPERVISOR_CONFLICT");
|
|
88
|
+
// Registrations take the namespace-wide lock before the task lock. Stop,
|
|
89
|
+
// pause and sends only take the task lock and never acquire in reverse order.
|
|
90
|
+
return withStoreLock("supervisor-registration", () => withStoreLock(`supervisor-${threadId}`, async () => {
|
|
91
|
+
const current = await assertSupervisorRegistration(threadId, sessionId, { connection, ...storage });
|
|
92
|
+
if (current?.session_id === sessionId) return current;
|
|
60
93
|
const now = new Date().toISOString();
|
|
61
94
|
const record = { schema: 1, thread_id: threadId, session_id: sessionId, owner, launcher_pid: launcherPid, connection,
|
|
62
|
-
state: "active", registered_at: now, updated_at: now, last_delivery: null };
|
|
95
|
+
state: "active", stopped_session_ids: stoppedSessionIds(current), registered_at: now, updated_at: now, last_delivery: null };
|
|
63
96
|
await writeRecord(supervisorBucket(threadId), threadId, record, storage);
|
|
64
97
|
return record;
|
|
65
|
-
}, storage);
|
|
98
|
+
}, storage), storage);
|
|
66
99
|
}
|
|
67
100
|
|
|
68
101
|
export async function controlSupervisor(threadInput, action, { expectedSession, ...storage } = {}) {
|
|
@@ -76,23 +109,32 @@ export async function controlSupervisor(threadInput, action, { expectedSession,
|
|
|
76
109
|
if (record.state === "stopped" && action !== "stop") throw failure("A stopped supervisor cannot be resumed. Start a new supervisor.", "SUPERVISOR_STOPPED");
|
|
77
110
|
if (action === "resume") await assertSupervisor(threadId, record.session_id, storage);
|
|
78
111
|
const updated = { ...record, state: { pause: "paused", resume: "active", stop: "stopped" }[action], updated_at: new Date().toISOString() };
|
|
112
|
+
updated.stopped_session_ids = stoppedSessionIds(updated);
|
|
79
113
|
await writeRecord(supervisorBucket(threadId), threadId, updated, storage);
|
|
80
114
|
return updated;
|
|
81
115
|
}, storage);
|
|
82
116
|
}
|
|
83
117
|
|
|
84
|
-
export async function supervisorStatus(threadInput, storage = {}) {
|
|
118
|
+
export async function supervisorStatus(threadInput, { allowObservationConflict = false, ...storage } = {}) {
|
|
85
119
|
const threadId = normalizeTaskId(threadInput), record = await getSupervisor(threadId, storage);
|
|
86
120
|
if (!record) return { thread_id: threadId, state: "unregistered", observation: null };
|
|
87
|
-
|
|
121
|
+
let observation = await readRecord("supervisor-observations", record.session_id, storage), observationError;
|
|
122
|
+
if (allowObservationConflict && observation?.schema === 1 && observation.session_id === record.session_id && observation.thread_id !== threadId) {
|
|
123
|
+
observationError = { code: "SUPERVISOR_INVALID", message: "The observation belongs to another task.", thread_id: observation.thread_id };
|
|
124
|
+
observation = null;
|
|
125
|
+
}
|
|
88
126
|
if (observation && (observation.schema !== 1 || observation.thread_id !== threadId || observation.session_id !== record.session_id)) throw failure("Invalid supervisor observation record.", "SUPERVISOR_INVALID");
|
|
89
|
-
return { ...record, launcher_alive: launcherAlive(record),
|
|
127
|
+
return { ...record, launcher_alive: launcherAlive(record), ...(observationError ? { observation_error: observationError } : {}),
|
|
90
128
|
observation, observation_age_ms: observation?.last_observed_at ? Math.max(0, Date.now() - Date.parse(observation.last_observed_at)) : null };
|
|
91
129
|
}
|
|
92
130
|
|
|
93
131
|
export async function listSupervisors(storage = {}) {
|
|
94
|
-
const records =
|
|
95
|
-
return Promise.all(records.
|
|
132
|
+
const records = await supervisorRecords(storage);
|
|
133
|
+
return Promise.all(records.map(async record => {
|
|
134
|
+
const status = await supervisorStatus(record.thread_id, { ...storage, allowObservationConflict: true });
|
|
135
|
+
const conflicts = records.filter(other => other.session_id === record.session_id).map(other => other.thread_id);
|
|
136
|
+
return conflicts.length > 1 ? { ...status, session_conflict: { thread_ids: conflicts.sort() } } : status;
|
|
137
|
+
}));
|
|
96
138
|
}
|
|
97
139
|
|
|
98
140
|
// Observation has its own record so a concurrent read never overwrites pause/stop.
|