feat(missions): let Pause finish the steps in progress, with Interrupt now (#1219)

A user reported that pausing a mission stopped it abruptly, mid-step; pausing by hand ("pause this session" queued to the coordinator and sub-agent) let the current step finish and resumed cleanly. Pause interrupted the whole native family at once.

Pause from the panel is now graceful (`graceful: true`): it records a paused control with no targets and no native side effect, so it settles at once. The paused context every actor reads at each model request asks it to finish only the step in progress, start no new work or sub-agents and end its turn; the plugin's context hook adds it for native descendants, walking ordinary sub-agents' ancestry (bounded, cached parent IDs) to their actor and re-reading a paused chain so only sessions still placed with the actor's admitted project and Location receive it. Dispatch blocking and report parking apply at once, as before.

While the activity projection observes a running, queued, background, Form or permission actor of a gracefully paused mission, the panel shows "Pausing: the steps in progress are finishing." and Interrupt now, which sends the existing immediate Pause (family interruption and receipts unchanged). An immediate Pause is valid from paused only after a graceful one. Play after a graceful Pause tells the coordinator that delegated work may have stopped early and should be continued, not restarted. The flag is carried by the route, RPC schema, plugin, journal (graceful means no targets), projection and UI intents; requests without it keep the immediate behavior.

MISSIONS.md, the Missions help (ten locales) and three UI strings (ten locales) are updated. Tests cover the lifecycle transitions and replay, the journal rule, the real plugin context hook for the coordinator and three levels of sub-agents (including moved sessions and warm caches), and the real panel for running and Form-waiting work. Three review rounds; the last had no findings.
This commit is contained in:
Pascal André 2026-10-31 00:33:06 +01:00 • committed by GitHub
parent 56823af672
commit 6058206699
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
51 changed files with 353 additions and 43 deletions

View file

@ -272,7 +272,9 @@ The UI creates missions with `prepared: true`. Its one-screen form has a single
`mission.control-requested` records the durable desired state and exact root actors before native side effects; `mission.control-applied` receipts track acknowledgements. The project mutation lock serializes controls with assignments and report admissions. Partial failure returns `503 control-pending`, and the snapshot retains the original request and remaining targets for an explicit retry, including after a UI/plugin restart. Old completed requests cannot replay their side effects over newer states. Stop may supersede an incomplete start/pause; no lifecycle action can restart a stopped/completed/failed mission (a failed one resumes only through coordinator recovery, §Targeted recovery). Receipt capacity is reserved before intent publication.
Pause immediately calls native `session.interrupt({ resume: false })` for the coordinator and all specialist roots, and first for every active native descendant of each root (see recursive interruption below), preserves task/inbox context, blocks new Mission dispatch/revision/finalization, and parks report notifications. A report arriving while paused is saved without waking the coordinator. Play resumes the coordinator and roots with unfinished admitted work via stable-ID native synthetic admissions, with fresh server-side environment and Git context and unchanged native agent/model selection. Play never creates or dispatches a new task graph.
The panel's Pause is graceful (`graceful: true`, Pause only): it records a paused control with no targets and no native side effect, so it settles at once and never needs a retry. Every actor reads the paused context at its next model request (`MissionControl.contextFor`, `missionPausedInstruction`), and the plugin's context hook adds it for native descendants too: finish only the step in progress, start no new work or sub-agents, and end the turn. Ordinary native sub-agents are not actors, so the hook walks their native ancestry (bounded, parent IDs cached) to the nearest actor; for a paused Mission it re-reads that chain and adds the instruction only when every session still links to its parent and shares the actor's admitted project and Location, like the immediate family walk. Paused blocking and report parking below apply from the moment it is recorded. While the activity projection observes a running, queued, background, Form or permission actor of a gracefully paused Mission (a waiting family member outranks running ones in the projection; `missionActivityWorking`), the panel shows "Pausing: the steps in progress are finishing." and **Interrupt now**, which sends an immediate Pause; an immediate Pause is valid from `paused` only after a graceful one, and a graceful Pause only from `running` or `prepared`. Play after a graceful Pause projects `pausedDescendants.graceful`, and the coordinator's resume message says delegated work may have stopped early and should be continued, not restarted.
An immediate Pause calls native `session.interrupt({ resume: false })` for the coordinator and all specialist roots, and first for every active native descendant of each root (see recursive interruption below), preserves task/inbox context, blocks new Mission dispatch/revision/finalization, and parks report notifications. A report arriving while paused is saved without waking the coordinator. Play resumes the coordinator and roots with unfinished admitted work via stable-ID native synthetic admissions, with fresh server-side environment and Git context and unchanged native agent/model selection. Play never creates or dispatches a new task graph.
Stop is irreversible for that mission. It interrupts those roots, cancels this mission's pending native inbox inputs, retains conversations/results, and withdraws unfinished tasks. Native controls always pass through the authenticated desktop bridge, authoritative snapshot, exact ownership/location and worktree/connection fences. Interruption is acknowledged per session; an already running tool process can outlive it.

View file

@ -163,7 +163,7 @@ Missionen funktionieren mit den Desktop-Apps und mit einem eigenständigen CodeN
3. Verwenden Sie bei unbestätigtem Ergebnis **Status prüfen** / **Steuerungsstatus prüfen**, um das vorhandene Ergebnis zu lesen. Fordern Sie keine Ersatzaktion an, um Ungewissheit zu umgehen.
4. Bei einmaligen Aktionen prüft **Aktualisieren und die unbestätigte Aktion erneut senden** im Kopf zuerst den Zustand und sendet nur genau diese weiterhin unbestätigte Aktion durch Ihren ausdrücklichen Klick erneut.
Einmalige Pause unterbricht die native Missionsfamilie und parkt die Koordination; Stop storniert zusätzlich wartende Familieneingaben/-zustellungen und ist endgültig. Diese Vorgänge sind begrenzt: **Einige Sub-Agenten laufen möglicherweise noch.** bedeutet unvollständigen Stopp. Prüfen Sie das erhaltene Ergebnis und verwenden Sie dessen ausdrücklichen Wiederholungsversuch, statt den Abbruch aller Shell-/Werkzeugauswirkungen anzunehmen. Fortsetzen informiert den Koordinator über unterbrochene Nachfahren; er muss über die Fortsetzung der Arbeit entscheiden.
Einmalige Pause lässt laufende Arbeit zu Ende gehen: Der Koordinator und seine Sub-Agenten schließen ihren aktuellen Schritt ab, beginnen nichts Neues und halten an; das Panel zeigt währenddessen **Wird pausiert: Laufende Schritte werden abgeschlossen.** **Jetzt unterbrechen** hält sie stattdessen sofort an: Es unterbricht die native Missionsfamilie und parkt die Koordination. Stop unterbricht ebenfalls, storniert wartende Familieneingaben/-zustellungen und ist endgültig. Unterbrechungen sind begrenzt: **Einige Sub-Agenten laufen möglicherweise noch.** bedeutet, dass eine unvollständig ist. Prüfen Sie das erhaltene Ergebnis und verwenden Sie dessen ausdrücklichen Wiederholungsversuch, statt den Abbruch aller Shell-/Werkzeugauswirkungen anzunehmen. Fortsetzen informiert den Koordinator darüber, was pausiert oder unterbrochen wurde; er entscheidet über die Fortsetzung der Arbeit.
Tägliche Pause/Stop beenden die Planung und fordern die Unterbrechung registrierter Wurzelakteure einschließlich Koordinator und unabhängiger Spezialisten an, **beweisen aber nicht, dass diese Unterhaltungen oder ihre Nachfahren gestoppt wurden**. Prüfen Sie die nativen Familien. Stop ist endgültig: Ein gestoppter Zeitplan kann weder gestartet noch fortgesetzt noch sofort ausgeführt werden. **Durchlauf prüfen** gleicht einen offenen Lauf ab, ohne erneut zu senden. Vorübergehende geplante Prüffehler zeigen den letzten Fehler und werden automatisch wiederholt; eine verlorene Benutzersteuerungsantwort verursacht kein automatisches erneutes Senden der Steuerung.

View file

@ -163,7 +163,7 @@ Missions work with the desktop apps and with a standalone CodeNomad [server](ser
3. If the result is unconfirmed, use **Check status** / **Check control status** to read the existing outcome. Do not issue a replacement action to bypass uncertainty.
4. For one-time actions, the header's **Refresh and resend the unconfirmed action** first checks state and resends only that still-unconfirmed exact action from your explicit click.
One-time Pause interrupts the mission's native family and parks coordination; Stop also cancels queued family inputs/deliveries and is terminal. These are bounded operations: **Some sub-agents may still be running.** means the stop is incomplete. Inspect the retained result and use its explicit retry rather than assuming all shell/tool effects were cancelled. Resume tells the coordinator about interrupted descendants; it must decide how to continue the work.
One-time Pause lets the work in progress finish: the coordinator and its sub-agents complete the step they are on, start nothing new and stop, while the panel shows **Pausing: the steps in progress are finishing.** **Interrupt now** stops them at once instead: it interrupts the mission's native family and parks coordination. Stop also interrupts, cancels queued family inputs/deliveries and is terminal. Interruptions are bounded operations: **Some sub-agents may still be running.** means one is incomplete. Inspect the retained result and use its explicit retry rather than assuming all shell/tool effects were cancelled. Resume tells the coordinator what was paused or interrupted; it decides how to continue the work.
Daily Pause/Stop cancel scheduling and request interruption of registered root actors, including the coordinator and independent specialists, but **do not prove that those conversations or their descendants have stopped**. Inspect the native families. Stop is terminal: a stopped schedule cannot Play, Resume or Run now. **Check passage** reconciles a pending run without sending it again. Transient scheduled-check failures display their last failure and retry automatically; a lost user-control reply does not cause automatic control resending.

View file

@ -163,7 +163,7 @@ Las Misiones funcionan con las aplicaciones de escritorio y con un [servidor](se
3. Si el resultado no está confirmado, usa **Comprobar estado** / **Comprobar el estado del control** para leer el resultado existente. No emitas una acción de reemplazo para eludir la incertidumbre.
4. Para acciones puntuales, **Actualizar y reenviar la acción no confirmada** de la cabecera comprueba primero el estado y reenvía únicamente esa acción exacta aún no confirmada desde tu clic explícito.
Pausar una misión puntual interrumpe su familia nativa y suspende la coordinación; Detener también cancela las entradas/entregas familiares en cola y es terminal. Son operaciones acotadas: **Es posible que algunos subagentes sigan en ejecución.** significa que la detención es incompleta. Inspecciona el resultado conservado y usa su reintento explícito en lugar de suponer que se cancelaron todos los efectos de shells/herramientas. Reanudar informa al coordinador de los descendientes interrumpidos; este debe decidir cómo continuar el trabajo.
Pausar una misión puntual deja terminar el trabajo en curso: el coordinador y sus subagentes completan el paso en el que están, no inician nada nuevo y se detienen, mientras el panel muestra **Pausando: los pasos en curso están terminando.** **Interrumpir ahora** los detiene en cambio de inmediato: interrumpe la familia nativa de la misión y suspende la coordinación. Detener también interrumpe, cancela las entradas/entregas familiares en cola y es terminal. Las interrupciones son acotadas: **Es posible que algunos subagentes sigan en ejecución.** significa que una está incompleta. Inspecciona el resultado conservado y usa su reintento explícito en lugar de suponer que se cancelaron todos los efectos de shells/herramientas. Reanudar informa al coordinador de lo que se pausó o interrumpió; este decide cómo continuar el trabajo.
Pausar/Detener una programación diaria cancela la programación y solicita interrumpir los actores raíz registrados, incluidos el coordinador y los especialistas independientes, pero **no demuestra que esas conversaciones ni sus descendientes se hayan detenido**. Inspecciona las familias nativas. Detener es terminal: una programación detenida no puede iniciarse, reanudarse ni ejecutarse ahora. **Comprobar pasada** reconcilia una ejecución pendiente sin volver a enviarla. Los fallos transitorios de comprobación programada muestran su último error y se reintentan automáticamente; perder la respuesta a un control del usuario no causa su reenvío automático.

View file

@ -163,7 +163,7 @@ Les Missions fonctionnent avec les applications de bureau et avec un [serveur](s
3. Si le résultat n’est pas confirmé, utilisez **Vérifier l’état** / **Vérifier l’état du contrôle** pour lire le résultat existant. Ne lancez pas d’action de remplacement pour contourner l’incertitude.
4. Pour les actions ponctuelles, **Actualiser et renvoyer l’action non confirmée** dans l’en-tête vérifie d’abord l’état et ne renvoie que cette action exacte encore non confirmée, depuis votre clic explicite.
La pause ponctuelle interrompt la famille native de la mission et suspend la coordination ; l’arrêt annule aussi les entrées/livraisons familiales en file et est terminal. Ces opérations sont bornées : **Certains sous-agents sont peut-être encore actifs.** signifie que l’arrêt est incomplet. Inspectez le résultat conservé et utilisez son nouvel essai explicite au lieu de supposer que tous les effets shell/outils ont été annulés. La reprise informe le coordinateur des descendants interrompus ; il doit décider comment poursuivre.
La pause ponctuelle laisse finir le travail en cours : le coordinateur et ses sous-agents terminent l'étape en cours, ne lancent rien de nouveau puis s'arrêtent, pendant que le panneau affiche **Mise en pause : les étapes en cours se terminent.** **Interrompre maintenant** les arrête au contraire tout de suite : cela interrompt la famille native de la mission et suspend la coordination. L'arrêt interrompt aussi, annule les entrées/livraisons familiales en file et est terminal. Ces interruptions sont bornées : **Certains sous-agents sont peut-être encore actifs.** signifie que l'une d'elles est incomplète. Inspectez le résultat conservé et utilisez son nouvel essai explicite au lieu de supposer que tous les effets shell/outils ont été annulés. La reprise informe le coordinateur de ce qui a été mis en pause ou interrompu ; il décide comment poursuivre.
La pause/l’arrêt quotidien annule la planification et demande l’interruption des acteurs racines enregistrés, dont le coordinateur et les spécialistes indépendants, mais **ne prouve pas que ces conversations ou leurs descendants sont arrêtés**. Inspectez les familles natives. L’arrêt est terminal : un programme arrêté ne peut pas démarrer, reprendre ou lancer maintenant. **Vérifier le passage** réconcilie une exécution en attente sans la renvoyer. Les échecs transitoires de vérification planifiée affichent leur dernier échec et sont réessayés automatiquement ; une réponse perdue à une commande utilisateur ne provoque pas son renvoi automatique.

View file

@ -163,7 +163,7 @@
3. אם התוצאה אינה מאושרת, השתמשו ב־**בדיקת מצב** / **בדיקת מצב הבקרה** לקריאת התוצאה הקיימת. אל תוציאו פעולה חלופית כדי לעקוף אי־ודאות.
4. לפעולות חד־פעמיות, **רענון ושליחה מחדש של הפעולה שלא אושרה** בכותרת בודק תחילה מצב ושולח מחדש רק את אותה פעולה מדויקת שעדיין אינה מאושרת, בעקבות לחיצה מפורשת שלכם.
השהיה חד־פעמית קוטעת את המשפחה המקורית של המשימה ומשהה תיאום; עצירה מבטלת גם קלטים/מסירות בתור של המשפחה והיא סופית. אלו פעולות מוגבלות: **ייתכן שחלק מתתי-הסוכנים עדיין פועלים.** אומר שהעצירה אינה שלמה. בדקו את התוצאה שנשמרה והשתמשו בניסיון החוזר המפורש שלה במקום להניח שכל השפעות המעטפת/הכלים בוטלו. חידוש מודיע למתאם על צאצאים שנקטעו; עליו להחליט כיצד להמשיך את העבודה.
השהיה חד־פעמית מאפשרת לעבודה הנוכחית להסתיים: המתאם ותתי-הסוכנים שלו משלימים את השלב שבו הם נמצאים, אינם מתחילים דבר חדש ועוצרים, בזמן שהלוח מציג **בהשהיה: השלבים הפעילים מסתיימים.** **הפסקה עכשיו** עוצר אותם מיד במקום זאת: הוא קוטע את המשפחה המקורית של המשימה ומשהה תיאום. עצירה גם קוטעת, מבטלת קלטים/מסירות בתור של המשפחה והיא סופית. הקטיעות מוגבלות: **ייתכן שחלק מתתי-הסוכנים עדיין פועלים.** אומר שאחת מהן אינה שלמה. בדקו את התוצאה שנשמרה והשתמשו בניסיון החוזר המפורש שלה במקום להניח שכל השפעות המעטפת/הכלים בוטלו. חידוש מודיע למתאם מה הושהה או נקטע; הוא מחליט כיצד להמשיך את העבודה.
השהיה/עצירה יומיות מבטלות תזמון ומבקשות קטיעה של גורמי שורש רשומים, לרבות המתאם והמומחים העצמאיים, אך **אינן מוכיחות שאותן שיחות או צאצאיהן נעצרו**. בדקו את המשפחות המקוריות. עצירה היא סופית: לוח זמנים שנעצר אינו יכול לפעול, להתחדש או לרוץ עכשיו. **בדיקת ההרצה** מיישב הרצה ממתינה בלי לשלוח אותה שוב. כשלי בדיקה מתוזמנת זמניים מציגים את הכשל האחרון שלהם ומנסים שוב אוטומטית; אובדן תשובה לבקרת משתמש אינו גורם לשליחה אוטומטית מחדש של הבקרה.

View file

@ -163,7 +163,7 @@ CodeNomad の実行中は、ミッションのコーディネーターやタス
3. 結果が未確認なら、**状態を確認**/**制御状況を確認** で既存の結果を読みます。不確実性を回避するための代替操作は送らないでください。
4. 1 回限りの操作では、ヘッダーの **更新して未確認の操作を再送信** がまず状態を確認し、明示的なクリックから、その同一の操作がまだ未確認の場合に限り再送します。
1 回限りの一時停止は、ミッションのネイティブファミリーを中断し、調整を保留します。停止はさらに、キューにあるファミリーの入力/配信をキャンセルし、終端状態になります。処理には上限があり、**一部のサブエージェントがまだ実行中の可能性があります。** は停止が不完全であることを意味します。すべてのシェル/ツールの影響がキャンセルされたと想定せず、保持された結果を確認し、明示的な再試行を使用してください。再開時には中断した子孫がコーディネーターに通知され、作業の続け方を判断する必要があります。
1 回限りの一時停止では、進行中の作業を終えてから止まります。コーディネーターとサブエージェントは現在のステップを完了し、新しい作業を始めずに停止します。その間、パネルには **一時停止中: 実行中のステップを完了しています。** と表示されます。**今すぐ中断** を使うと代わりにすぐ止まり、ミッションのネイティブファミリーを中断して調整を保留します。停止も中断を行い、キューにあるファミリーの入力/配信をキャンセルし、終端状態になります。中断には上限があり、**一部のサブエージェントがまだ実行中の可能性があります。** はいずれかが不完全であることを意味します。すべてのシェル/ツールの影響がキャンセルされたと想定せず、保持された結果を確認し、明示的な再試行を使用してください。再開時には一時停止または中断された内容がコーディネーターに通知され、作業の続け方はコーディネーターが判断します。
毎日の一時停止/停止は、スケジュールをキャンセルし、コーディネーターと独立した専門担当を含む登録済みルート担当の中断を要求しますが、**それらの会話や子孫が停止した証拠にはなりません**。ネイティブのファミリーを確認してください。停止は終端です。停止済みのスケジュールは、開始、再開、今すぐ実行を行えません。**実行を確認** は、再送せずに保留中の実行を照合します。一時的な定期確認の失敗は、最後の失敗を表示し自動再試行しますが、ユーザー制御の応答消失で制御を自動再送することはありません。

View file

@ -163,7 +163,7 @@ restart पछि pending run जाँचेर तयार हुँदा **
3. नतिजा अपुष्ट भए विद्यमान outcome पढ्न **अवस्था जाँच गर्नुहोस्** / **नियन्त्रणको स्थिति जाँच्नुहोस्** प्रयोग गर्नुहोस्। अनिश्चितता छल्न replacement action नदिनुहोस्।
4. one-time कार्यमा header को **ताजा गर्नुहोस् र पुष्टि नभएको कार्य पुनः पठाउनुहोस्** ले तपाईंको स्पष्ट क्लिकबाट पहिले state जाँच्छ र अझै अपुष्ट ठ्याक्कै त्यही कार्य मात्र resend गर्छ।
one-time Pause ले native mission परिवार interrupt गरी coordination रोक्छ; Stop ले queued family input/delivery पनि रद्द गर्छ र terminal हो। यी bounded कार्य हुन्: **केही उप-एजेन्टहरू अझै चलिरहेका हुन सक्छन्।** को अर्थ stop अधुरो छ। सबै shell/tool प्रभाव रद्द भए नठानी retained result जाँचेर explicit retry प्रयोग गर्नुहोस्। Resume ले interrupted descendant बारे coordinator लाई बताउँछ; कसरी जारी राख्ने उसैले निर्णय गर्छ।
one-time Pause ले चलिरहेको काम सकिन दिन्छ: coordinator र यसका उप-एजेन्टहरूले हालको चरण पूरा गर्छन्, नयाँ केही सुरु गर्दैनन् र रोकिन्छन्; त्यस बेला panel ले देखाउँछ: **रोकिँदै: चलिरहेका चरणहरू सकिँदैछन्।** **अहिले नै रोक्नुहोस्** ले भने तिनलाई तुरुन्तै रोक्छ: native mission परिवार interrupt गरी coordination रोक्छ। Stop ले पनि interrupt गर्छ, queued family input/delivery रद्द गर्छ र terminal हो। interruption हरू bounded कार्य हुन्: **केही उप-एजेन्टहरू अझै चलिरहेका हुन सक्छन्।** को अर्थ कुनै एउटा अधुरो छ। सबै shell/tool प्रभाव रद्द भए नठानी retained result जाँचेर explicit retry प्रयोग गर्नुहोस्। Resume ले के pause वा interrupt भयो भनेर coordinator लाई बताउँछ; कसरी जारी राख्ने उसैले निर्णय गर्छ।
daily Pause/Stop ले scheduling रद्द गरी coordinator र independent specialist समेत registered root actor को interruption माग्छन्, तर **ती कुराकानी वा तिनका descendant रोकिएको प्रमाण दिँदैनन्**। native परिवार जाँच्नुहोस्। Stop terminal हो: stopped schedule ले Play, Resume वा Run now गर्न सक्दैन। **पालो जाँच गर्नुहोस्** ले pending run फेरि नपठाई reconcile गर्छ। अस्थायी scheduled-check failure ले अन्तिम त्रुटि देखाउँछन् र स्वतः retry गर्छन्; user-control reply हराउँदा automatic control resend हुँदैन।

View file

@ -163,7 +163,7 @@
3. Если результат не подтверждён, используйте **Проверить статус** / **Проверить состояние управления** для чтения существующего исхода. Не выдавайте заменяющее действие в обход неопределённости.
4. Для разовых действий **Обновить и повторно отправить неподтверждённое действие** в заголовке сначала проверяет состояние и повторяет только то же точное всё ещё неподтверждённое действие по вашему явному нажатию.
Разовая пауза прерывает нативное семейство миссии и приостанавливает координацию; остановка также отменяет семейные входящие/доставки в очереди и окончательна. Операции ограничены: **Некоторые субагенты, возможно, ещё работают.** означает неполную остановку. Проверьте сохранённый результат и используйте явный повтор, а не предполагайте отмену всех последствий оболочки/инструментов. Возобновление сообщает координатору о прерванных потомках; он должен решить, как продолжать.
Разовая пауза даёт завершить текущую работу: координатор и его субагенты заканчивают текущий шаг, не начинают ничего нового и останавливаются, а панель показывает **Приостановка: текущие шаги завершаются.** **Прервать сейчас** вместо этого останавливает их сразу: прерывает нативное семейство миссии и приостанавливает координацию. Остановка тоже прерывает, отменяет семейные входящие/доставки в очереди и окончательна. Прерывания ограничены: **Некоторые субагенты, возможно, ещё работают.** означает, что одно из них неполное. Проверьте сохранённый результат и используйте явный повтор, а не предполагайте отмену всех последствий оболочки/инструментов. Возобновление сообщает координатору, что было приостановлено или прервано; он решает, как продолжать.
Ежедневные пауза/остановка отменяют расписание и запрашивают прерывание зарегистрированных корневых участников, включая координатора и независимых специалистов, но **не доказывают, что эти разговоры или их потомки остановились**. Проверьте нативные семейства. Остановка окончательна: остановленное расписание нельзя запустить, возобновить или запустить сейчас. **Проверить прогон** согласует ожидающий запуск без повторной отправки. Временные сбои плановой проверки показывают последнюю ошибку и повторяются автоматически; потерянный ответ пользовательского управления не вызывает автоматического повтора управления.

View file

@ -163,7 +163,7 @@ Görevler masaüstü uygulamalarıyla ve bağımsız bir CodeNomad [sunucusuyla]
3. Sonuç doğrulanmadıysa mevcut sonucu okumak için **Durumu denetle** / **Kontrol durumunu denetle** eylemlerini kullanın. Belirsizliği aşmak için yeni bir ikame eylem göndermeyin.
4. Tek seferlik eylemlerde başlıktaki **Yenile ve onaylanmamış işlemi yeniden gönder**, önce durumu kontrol eder; yalnızca açık tıklamanız üzerine hâlâ doğrulanmamış aynı eylemi yeniden gönderir.
Tek seferlik Duraklat, görevin OpenCode konuşma ailesini kesintiye uğratıp koordinasyonu bekletir; Durdur ayrıca ailede sıradaki girdileri/teslimatları iptal eder ve kalıcıdır. Bunlar sınırlı işlemlerdir: **Bazı alt ajanlar hâlâ çalışıyor olabilir.** durdurmanın tamamlanmadığını gösterir. Tüm kabuk/araç etkilerinin iptal edildiğini varsaymak yerine korunan sonucu inceleyip açık yeniden deneme eylemini kullanın. Sürdürme, koordinatöre kesintiye uğrayan alt konuşmaları bildirir; işin nasıl devam edeceğine koordinatör karar vermelidir.
Tek seferlik Duraklat devam eden işin bitmesine izin verir: koordinatör ve alt ajanları bulundukları adımı tamamlar, yeni bir şey başlatmaz ve durur; bu sırada panelde şu görünür: **Duraklatılıyor: devam eden adımlar tamamlanıyor.** **Şimdi kes** ise onları hemen durdurur: görevin OpenCode konuşma ailesini kesintiye uğratıp koordinasyonu bekletir. Durdur da kesintiye uğratır, ailede sıradaki girdileri/teslimatları iptal eder ve kalıcıdır. Kesintiler sınırlı işlemlerdir: **Bazı alt ajanlar hâlâ çalışıyor olabilir.** birinin tamamlanmadığını gösterir. Tüm kabuk/araç etkilerinin iptal edildiğini varsaymak yerine korunan sonucu inceleyip açık yeniden deneme eylemini kullanın. Sürdürme, koordinatöre neyin duraklatıldığını veya kesildiğini bildirir; işin nasıl devam edeceğine koordinatör karar verir.
Günlük Duraklat/Durdur zamanlamayı iptal eder ve koordinatörle bağımsız uzmanlar dahil kayıtlı kök aktörlerin kesilmesini ister; ancak **bu konuşmaların veya alt konuşmalarının durduğunu kanıtlamaz**. OpenCode konuşma ailelerini inceleyin. Durdurma kalıcıdır: durdurulmuş zamanlama Başlat, Sürdür veya Şimdi çalıştır eylemlerini kullanamaz. **Çalıştırmayı denetle** bekleyen çalıştırmayı tekrar göndermeden uzlaştırır. Geçici zamanlanmış denetim hataları son hatayı gösterir ve otomatik yeniden denenir; kullanıcı kontrolüne ait yanıtın kaybolması kontrolün otomatik yeniden gönderilmesine yol açmaz.

View file

@ -163,7 +163,7 @@ CodeNomad 运行期间,项目中的每个智能体对话(包括任务协调
3. 结果未确认时,使用**检查状态** / **检查控制状态**读取已有结果。不要以替换操作绕过不确定性。
4. 对一次性操作,标题栏的**刷新并重新发送未确认的操作**会先检查状态,仅在你显式点击时重发仍未确认的同一精确操作。
一次性暂停会中断任务原生会话族并挂起协调;停止还会取消排队的会话族输入/交付,且为终止状态。这些操作有边界:**部分子代理可能仍在运行。**表示停止不完整。检查保留结果并使用其显式重试,而非假定所有 Shell/工具影响已取消。继续会告知协调者哪些后代被中断;由它决定如何继续工作。
一次性暂停会让进行中的工作先完成:协调者及其子代理完成当前步骤、不再开始新工作然后停止,期间面板显示**正在暂停:进行中的步骤正在完成。** **立即中断**则会立即停止它们:中断任务原生会话族并挂起协调。停止同样会中断,还会取消排队的会话族输入/交付,且为终止状态。中断有边界:**部分子代理可能仍在运行。**表示其中有一项不完整。检查保留结果并使用其显式重试,而非假定所有 Shell/工具影响已取消。继续会告知协调者哪些内容被暂停或中断;由它决定如何继续工作。
每日暂停/停止会取消调度并请求中断已注册根参与者,包括协调者和独立专家,但**不能证明这些对话或其后代已停止**。请检查原生会话族。停止是终止状态:停止的日程不能启动、继续或立即运行。**检查本次运行**会协调待处理运行,不会重新发送。临时计划检查失败会显示上次失败并自动重试;用户控制回复丢失不会引发自动控制重发。

View file

@ -12,6 +12,7 @@ import {
type MissionActor,
type MissionEvent,
type MissionList,
type MissionLocation,
type MissionMap,
type MissionRead,
type MissionReport,
@ -37,7 +38,7 @@ import { missionRecoveryInput, type MissionRecoveryInput } from "./recovery-inpu
import { MissionControlError, MissionCreateNoEffectError, decisionInvocationUnavailable, humanDecisionRequired } from "./control-error"
import { deleteMission, missionCleanupTarget } from "./session-cleanup"
import { controlMission } from "./lifecycle"
import { missionIsRunning, type MissionLifecycleInput } from "./lifecycle-model"
import { missionIsRunning, missionPausedInstruction, type MissionLifecycleInput } from "./lifecycle-model"
import { normalizeTaskDeclaration, taskContractReferenceSchema, validateTaskActorChoice, validateTaskAdmissionGraph, validateMissionTaskMode, type TaskContractReference } from "./task-declaration"
import { compareModelError, freezeCompareProfiles, parseMissionProfiles, sameMissionProfiles, validateMissionProfiles, type MissionProfiles } from "./playbook-profiles"
import { hasUnsettledNativeExecution } from "./native-call-observation"
@ -1274,14 +1275,22 @@ export class MissionControl {
/** The per-turn context of an active mission's actor, and whether it coordinates
* that mission. From hints: a missed foreign write withholds the context until
* the next rebuild, never grants anything (every tool re-selects authoritatively). */
async contextFor(sessionID: string): Promise<{ instruction: string; coordinator: boolean } | undefined> {
async contextFor(sessionID: string): Promise<{ instruction: string; coordinator: boolean; missionID: string; paused: boolean
/** The actor's admitted placement, which its native descendants must share. */
placement: { projectID: string; location: MissionLocation } } | undefined> {
const mission = this.membership(await this.hintedSelection(sessionID), sessionID)
if (!mission || mission.status !== "active") return undefined
const actor = mission.actors.find(candidate => candidate.sessionId === sessionID)
if (!actor) return undefined
const placement = { projectID: mission.projectID, location: actor.location }
const coordinator = mission.coordinatorSessionId === sessionID
if (!missionIsRunning(mission)) return { coordinator, instruction: `Mission ${mission.id} is ${mission.runState}. Do not continue mission work or create a replacement mission. Wait for the user's Play action. Saved reports may still be recorded without waking the coordinator.` }
const paused = !mission.controlUnavailable && mission.runState === "paused"
if (!missionIsRunning(mission)) return { coordinator, missionID: mission.id, paused, placement, instruction: paused
? `${missionPausedInstruction(mission.id)} Saved reports may still be recorded without waking the coordinator.`
: `Mission ${mission.id} is ${mission.runState}. Do not continue mission work or create a replacement mission. Wait for the user's Play action. Saved reports may still be recorded without waking the coordinator.` }
const instruction = buildActorContext(mission, sessionID, { backendActions: this.backendActions,
completedFinalOnly: this.options.completedFinalOnly })
return instruction ? { instruction, coordinator } : undefined
return instruction ? { instruction, coordinator, missionID: mission.id, paused: false, placement } : undefined
}
/** Whether coordinator actions can reach the backend (one-time missions with presence, not passages). */

View file

@ -469,7 +469,11 @@ export function parseMissionEvent(input: unknown): MissionEvent | undefined {
}
case "mission.control-requested": {
if (!text(input.requestID, 128) || !Number.isSafeInteger(input.expectedRevision) || Number(input.expectedRevision) < 1
|| !["start", "pause", "stop"].includes(String(input.action)) || !Array.isArray(input.targets) || input.targets.length > MISSION_MAX_ACTORS || !input.targets.length) return undefined
|| !["start", "pause", "stop"].includes(String(input.action)) || !Array.isArray(input.targets) || input.targets.length > MISSION_MAX_ACTORS) return undefined
// A graceful Pause interrupts nothing and has no targets; every other control has some.
const graceful = input.graceful
if (graceful !== undefined && (graceful !== true || input.action !== "pause")) return undefined
if (graceful ? input.targets.length : !input.targets.length) return undefined
const targets: Array<{ sessionID: string; location: MissionLocation }> = []
const recurrence = input.recurrence
if (recurrence !== undefined && (!record(recurrence) || input.action !== "start"
@ -485,6 +489,7 @@ export function parseMissionEvent(input: unknown): MissionEvent | undefined {
}
if (recurrence !== undefined && (targets.length !== 1 || targets[0].sessionID !== recurrence.coordinatorSessionID)) return undefined
return { ...eventBase(input), type: "mission.control-requested", requestID: input.requestID, expectedRevision: Number(input.expectedRevision), action: input.action as "start" | "pause" | "stop", targets,
...(graceful ? { graceful: true as const } : {}),
...(recurrence === undefined ? {} : { recurrence: { grantID: recurrence.grantID as string, passageID: recurrence.passageID as string,
messageID: recurrence.messageID as string, coordinatorSessionID: recurrence.coordinatorSessionID as string } }) }
}

View file

@ -32,6 +32,10 @@ export interface MissionLifecycleInput {
requestID: string
expectedRevision: number
action: MissionAction
/** Pause only: interrupt nothing. Actors and their sub-agents read the paused
* context at their next model request, finish the step in progress and end
* their turn. Absent: interrupt the native family at once. */
graceful?: true
/** Internal signed finite passage only; ordinary lifecycle omits this. */
recurrence?: { grantID: string; passageID: string; messageID: string; coordinatorSessionID: string }
}
@ -42,8 +46,9 @@ export interface MissionLifecycleOperation extends MissionLifecycleInput {
receipts?: MissionLifecycleReceipt[]
/** Journal revision when this exact operation's final target ACK was saved. */
completedRevision?: number
/** Start only: descendant sessions the latest preceding Pause reported interrupting. */
pausedDescendants?: { sessions: string[]; partial: boolean }
/** Start only: descendant sessions the latest preceding Pause reported
* interrupting, or `graceful` when that Pause interrupted nothing. */
pausedDescendants?: { sessions: string[]; partial: boolean; graceful?: true }
}
export interface MissionControlRequestedEvent extends MissionLifecycleInput {
version: 1
@ -90,6 +95,7 @@ export function projectLifecycle(events: readonly MissionEvent[]): { runState: M
control: {
id: operation.id, missionID: operation.missionID, requestID: operation.requestID, expectedRevision: operation.expectedRevision,
action: operation.action, targets: operation.targets, pending, receipts,
...(operation.graceful ? { graceful: true as const } : {}),
...(operation.recurrence ? { recurrence: operation.recurrence } : {}),
...(completedRevision !== undefined ? { completedRevision } : {}),
...(paused ? { pausedDescendants: paused } : {}),
@ -103,6 +109,7 @@ function pausedDescendants(events: readonly MissionEvent[], start: MissionContro
const before = events.slice(0, events.indexOf(start))
const pause = [...before].reverse().find(event => event.type === "mission.control-requested")
if (!pause || pause.type !== "mission.control-requested" || pause.action !== "pause") return undefined
if (pause.graceful) return { sessions: [], partial: false, graceful: true as const }
const sessions = new Set<string>()
let partial = false
for (const target of pause.targets) {
@ -116,6 +123,15 @@ function pausedDescendants(events: readonly MissionEvent[], start: MissionContro
return { sessions: [...sessions], partial }
}
/** The context a paused mission's actors (or their native descendants) read at
* each model request: a graceful Pause stops the family through it. */
export function missionPausedInstruction(missionID: string, descendant = false): string {
return `Mission ${missionID} is paused by the user. Start no new mission work, sub-agents or replacement mission. `
+ (descendant
? "Finish only the step already in progress, return what you have to your parent, stating that the mission was paused and where you stopped, and end your turn."
: "Finish only the step already in progress, briefly state where you stopped, and end your turn. Wait for the user's Play action.")
}
export function missionIsRunning(mission: Pick<MissionMap, "status" | "runState" | "controlUnavailable">): boolean {
return !mission.controlUnavailable && mission.status === "active" && (mission.runState === undefined || mission.runState === "running")
}

View file

@ -71,16 +71,17 @@ export function parseMissionLifecycleReply(value: unknown, expected: Parameters<
/** Bounded strict lifecycle receipt projection for native recurrence snapshots. */
export const lifecycleOperationReadSchema = z.object({ id, missionID: id, requestID: id,
expectedRevision: z.number().int().positive().safe(), action: z.enum(["start", "pause", "stop"]),
expectedRevision: z.number().int().positive().safe(), action: z.enum(["start", "pause", "stop"]), graceful: z.literal(true).optional(),
recurrence: recurrence.optional(), completedRevision: z.number().int().positive().safe().optional(),
pausedDescendants: z.object({ sessions: z.array(id).max(MAX_REPORTED_DESCENDANTS), partial: z.boolean() }).strict().optional(),
pausedDescendants: z.object({ sessions: z.array(id).max(MAX_REPORTED_DESCENDANTS), partial: z.boolean(), graceful: z.literal(true).optional() }).strict().optional(),
targets: z.array(z.object({ sessionID: id, location: z.object({ directory: z.string().min(1).max(4096), workspaceID: id.optional() }).strict() }).strict()).max(32),
pending: z.array(id).max(32),
receipts: z.array(z.union([
z.object({ receiptID: id, sessionID: id, acknowledgementState: z.literal("known"), nativeAcknowledgement: acknowledgement }).strict(),
z.object({ receiptID: id, sessionID: id, acknowledgementState: z.literal("unknown") }).strict(),
])).max(32).optional(),
}).strict().refine(value => new Set(value.targets.map(target => target.sessionID)).size === value.targets.length
}).strict().refine(value => (value.graceful ? value.action === "pause" && !value.targets.length : value.targets.length > 0)
&& new Set(value.targets.map(target => target.sessionID)).size === value.targets.length
&& new Set(value.pending).size === value.pending.length && value.pending.every(session => value.targets.some(target => target.sessionID === session))
&& new Set(value.receipts?.map(receipt => receipt.sessionID)).size === (value.receipts?.length ?? 0)
&& (value.receipts ?? []).every(receipt => value.targets.some(target => target.sessionID === receipt.sessionID)
@ -114,6 +115,7 @@ export const lifecycleInputSchema = {
type: "object", properties: {
missionID: { type: "string", minLength: 1, maxLength: 100 }, requestID: { type: "string", minLength: 1, maxLength: 128 },
expectedRevision: { type: "integer", minimum: 1 }, action: { type: "string", enum: ["start", "pause", "stop"] },
graceful: { const: true },
}, required: ["missionID", "requestID", "expectedRevision", "action"], additionalProperties: false,
} as const
const ackIdentityProperties = { missionID: { type: "string", minLength: 1, maxLength: 240 },
@ -149,7 +151,7 @@ export const lifecycleOperationSchema = {
id: { type: "string" }, pending: { type: "array", items: { type: "string" } },
completedRevision: { type: "integer", minimum: 1 },
pausedDescendants: { type: "object", properties: { sessions: { type: "array", maxItems: MAX_REPORTED_DESCENDANTS,
items: ackIdentityProperties.sessionID }, partial: { type: "boolean" } }, required: ["sessions", "partial"], additionalProperties: false },
items: ackIdentityProperties.sessionID }, partial: { type: "boolean" }, graceful: { const: true } }, required: ["sessions", "partial"], additionalProperties: false },
// Native V2's JSON Schema decoder does not support `not`. Complete,
// disjoint object branches retain the exact known/unknown evidence rule.
receipts: { type: "array", maxItems: 8, items: { oneOf: [

View file

@ -131,6 +131,49 @@ test("a normal Pause of an active mission remains resumable", async () => {
assert.deepEqual(resumed.control?.pending, [])
})
test("a graceful Pause interrupts nothing, can be interrupted at once later, and tells Play that work may have stopped early", async () => {
const f = fixture(), prepared = await f.create()
const running = (await f.control().lifecycle(f.action(prepared, "start"))).mission
await assert.rejects(f.control().lifecycle({ ...f.action(running, "stop"), graceful: true }), code("control-conflict"))
const calls = f.calls.length
const paused = (await f.control().lifecycle({ ...f.action(running, "pause"), graceful: true })).mission
assert.equal(paused.runState, "paused")
assert.equal(paused.control?.graceful, true)
assert.deepEqual([paused.control?.targets, paused.control?.pending], [[], []], "nothing native to apply or retry")
assert.ok(paused.control?.completedRevision, "settled at once")
assert.equal(f.calls.length, calls, "no native interrupt")
// The same request replays to the same mission; a mismatched replay is refused.
assert.equal((await f.control().lifecycle({ ...f.action(running, "pause"), graceful: true })).mission.revision, paused.revision)
await assert.rejects(f.control().lifecycle(f.action(running, "pause")), code("request-conflict"))
const context = await f.control().contextFor(paused.coordinatorSessionId)
assert.equal(context?.paused, true)
assert.match(context?.instruction ?? "", /Finish only the step already in progress/)
// A second graceful Pause is not a valid transition; Interrupt now is.
await assert.rejects(f.control().lifecycle({ ...f.action(paused, "pause"), graceful: true }), code("control-conflict"))
const interrupted = (await f.control().lifecycle(f.action(paused, "pause"))).mission
assert.equal(interrupted.runState, "paused")
assert.equal(interrupted.control?.graceful, undefined)
assert.deepEqual(f.calls.slice(calls).map(call => [call.action, call.sessionID]), [["pause", paused.coordinatorSessionId]])
await assert.rejects(f.control().lifecycle(f.action(interrupted, "pause")), code("control-conflict"), "an interrupted Pause is final")
const resumed = (await f.control().lifecycle(f.action(interrupted, "start"))).mission
assert.equal(resumed.control?.pausedDescendants?.graceful, undefined, "the latest Pause interrupted")
const again = (await f.control().lifecycle({ ...f.action(resumed, "pause"), graceful: true })).mission
const play = (await f.control().lifecycle(f.action(again, "start"))).mission
assert.deepEqual(play.control?.pausedDescendants, { sessions: [], partial: false, graceful: true })
})
test("the journal keeps only graceful Pauses without targets", () => {
const base = { version: 1, id: "evt_control", projectID: "project", missionID: "msn_fixture", type: "mission.control-requested",
requestID: "request", expectedRevision: 1, createdAt: 1 }
const target = { sessionID: "ses_actor", location: { directory: "/repo" } }
assert.equal((parseMissionEvent({ ...base, action: "pause", graceful: true, targets: [] }) as { graceful?: true } | undefined)?.graceful, true)
for (const invalid of [{ action: "pause", graceful: true, targets: [target] }, { action: "start", graceful: true, targets: [] },
{ action: "pause", graceful: false, targets: [target] }, { action: "pause", targets: [] }]) {
assert.equal(parseMissionEvent({ ...base, ...invalid }), undefined, JSON.stringify(invalid))
}
})
test("stop records native target receipts after final report without rewriting its completed result", async () => {
const f = fixture(), prepared = await f.create()
const running = (await f.control().lifecycle(f.action(prepared, "start"))).mission

View file

@ -25,7 +25,7 @@ export async function controlMission(input: MissionLifecycleInput, options: {
const previous = await journal.event(input.missionID, eventID)
if (previous) {
if (previous.type !== "mission.control-requested" || previous.requestID !== input.requestID || previous.action !== input.action || previous.expectedRevision !== input.expectedRevision
|| !isDeepStrictEqual(previous.recurrence, input.recurrence)) {
|| Boolean(previous.graceful) !== Boolean(input.graceful) || !isDeepStrictEqual(previous.recurrence, input.recurrence)) {
throw new MissionControlError("Control request ID already used", "request-conflict")
}
// A completed old action must never undo a newer pause or terminal stop.
@ -40,11 +40,16 @@ export async function controlMission(input: MissionLifecycleInput, options: {
if (mission.revision !== input.expectedRevision) throw new MissionControlError("Mission changed; reload before controlling it", "revision-conflict")
if (mission.control?.pending.length && input.action === "start") throw new MissionControlError("Retry the pending control action first", "control-pending")
const state = mission.runState ?? "running"
if (input.graceful && input.action !== "pause") throw new MissionControlError("Only Pause can be graceful", "control-conflict")
// A graceful Pause can be followed by an immediate one for work still finishing.
const interruptsGracefulPause = input.action === "pause" && !input.graceful && state === "paused"
&& mission.control?.action === "pause" && Boolean(mission.control.graceful)
if ((input.action === "start" && state !== "prepared" && state !== "paused")
|| (input.action === "pause" && mission.status === "active" && state !== "running" && state !== "prepared")) {
|| (input.action === "pause" && mission.status === "active" && state !== "running" && state !== "prepared" && !interruptsGracefulPause)) {
throw new MissionControlError("Action is not valid in the current mission state", "control-conflict")
}
const targets = mission.actors.filter(actor => !mission!.tasks.some(task => task.actorSessionId === actor.sessionId && task.nativeBinding))
// A graceful Pause has no native effect: the paused context stops the family.
const targets = input.graceful ? [] : mission.actors.filter(actor => !mission!.tasks.some(task => task.actorSessionId === actor.sessionId && task.nativeBinding))
.filter(actor => input.action !== "start" || actor.kind === "coordinator"
|| mission!.tasks.some(task => task.actorSessionId === actor.sessionId && (task.status === "queued" || task.status === "dispatching" || task.outstandingExecution)))
.map(actor => ({ sessionID: actor.sessionId, location: { ...actor.location } }))

View file

@ -253,3 +253,75 @@ test("failed presence activation disposes partial registrations and fences captu
assert.deepEqual(disposed.sort(), ["rpc", "tools"])
await assert.rejects(captured[0].execute({}, {}), /no longer available/)
})
test("a graceful Pause reaches the coordinator and every ordinary native descendant at each model request", async () => {
const values = new Map<string, unknown>()
const tools: Array<{ name: string; execute(input: unknown, context: any): Promise<{ content: string }> }> = []
let contextHook: ((event: { sessionID: string; system: Array<{ type: "text"; text: string }>; tools: Record<string, unknown> }) => Promise<void>) | undefined
let lifecycle: ((input: unknown, context: any) => Promise<unknown>) | undefined
// Ordinary native sub-agents are never Mission actors: only their ancestry links them.
const parents: Record<string, string> = { ses_child: "ses_coordinator", ses_grandchild: "ses_child", ses_great: "ses_grandchild", ses_stranger_child: "ses_stranger" }
const reads: string[] = []
// Native moves keep the parent edge and change project or Location.
const moved: Record<string, { projectID?: string; directory?: string }> = {}
const registration = () => ({ dispose: async () => {} })
const cleanup = await setupMissionsPlugin({
location: { directory: "/repo", project: { id: "project-1", canonical: "/repo" } },
storage: {
get: async (key: string) => values.get(key),
set: async (key: string, value: unknown) => { values.set(key, structuredClone(value)) },
remove: async (key: string) => { values.delete(key) },
scan: async ({ prefix }: { prefix: string }) => ({ entries: [...values].filter(([key]) => key.startsWith(prefix)).map(([key, value]) => ({ key, value })) }),
},
session: {
get: async ({ sessionID }: { sessionID: string }) => {
reads.push(sessionID)
return { id: sessionID, projectID: moved[sessionID]?.projectID ?? "project-1", title: sessionID, location: { directory: moved[sessionID]?.directory ?? "/repo" }, ...(parents[sessionID] ? { parentID: parents[sessionID] } : {}) }
},
create: async () => { throw new Error("not used") }, prompt: async () => {}, synthetic: async () => {},
hook: async (_name: "context", callback: typeof contextHook) => { contextHook = callback; return registration() },
},
tool: { transform: async (callback: (draft: any) => void) => { callback({ namespace: () => {}, add: (tool: any) => tools.push(tool) }); return registration() } },
rpc: { register: async (_definition: unknown, handlers: { lifecycle(input: unknown, context: any): Promise<unknown> }) => {
lifecycle = handlers.lifecycle
return { ...registration(), events: { emit: async () => {} } }
} },
} as never)
try {
await tools.find(tool => tool.name === "inspect")!.execute({ start: { objective: "Coordinate", template: "custom" } },
{ sessionID: "ses_coordinator", messageID: "msg_1", id: "call_1", progress: async () => {} })
const context = async (sessionID: string) => {
const event = { sessionID, system: [] as Array<{ type: "text"; text: string }>, tools: {} as Record<string, unknown> }
await contextHook!(event)
return event.system.map(part => part.text).join("\n")
}
for (const id of ["ses_child", "ses_great"]) assert.doesNotMatch(await context(id), /paused/, "a running mission adds nothing to ordinary sub-agents")
// Once warm, a running Mission's sub-agents and unrelated families need no native read.
const warm = reads.length
for (const id of ["ses_great", "ses_stranger_child"]) await context(id)
await context("ses_stranger_child")
assert.deepEqual(reads.slice(warm).filter(id => id !== "ses_stranger_child" && id !== "ses_stranger"), [], "cached parents serve later requests")
const mission = JSON.parse((await tools.find(tool => tool.name === "inspect")!.execute({}, {
sessionID: "ses_coordinator", messageID: "msg_2", id: "call_2", progress: async () => {} })).content).mission as MissionMap
const result = await lifecycle!({ missionID: mission.id, requestID: "pause-gracefully", expectedRevision: mission.revision, action: "pause", graceful: true },
{ error: (type: string, message: string, data: unknown) => ({ type, message, data }) }) as { mission: MissionMap }
assert.equal(result.mission.control?.graceful, true)
assert.match(await context("ses_coordinator"), /paused by the user[\s\S]*end your turn\. Wait for the user's Play action/)
for (const id of ["ses_child", "ses_grandchild", "ses_great"]) {
assert.match(await context(id), new RegExp(`Mission ${mission.id} is paused by the user[\\s\\S]*return what you have to your parent`), id)
}
assert.doesNotMatch(await context("ses_stranger_child"), /paused/, "another conversation's sub-agent is untouched")
// A wake-up or the next step of the same sub-agent still receives it.
assert.match(await context("ses_great"), /paused by the user/)
// A moved family member no longer belongs to the admitted family, even with warm parents.
moved.ses_child = { directory: "/other-worktree" }
for (const id of ["ses_child", "ses_grandchild", "ses_great"]) assert.doesNotMatch(await context(id), /paused/, `${id} below a moved child`)
delete moved.ses_child
moved.ses_grandchild = { projectID: "foreign-project" }
assert.doesNotMatch(await context("ses_great"), /paused/, "an ancestor moved to another project breaks the family")
delete moved.ses_grandchild
assert.match(await context("ses_great"), /paused by the user/)
} finally { await cleanup() }
})

View file

@ -19,12 +19,13 @@ import type {
} from "../missions/control-types"
import { MISSION_SCHEMA_VERSION, type MissionEvent, type MissionJsonValue, type MissionMap, type MissionTemplateId } from "../missions/model"
import { parseMissionEvent } from "../missions/journal"
import { missionPausedInstruction } from "../missions/lifecycle-model"
import { CODENOMAD_MISSIONS_RPC } from "../missions/rpc"
import { executionSchema, parseExecution } from "../missions/execution"
import { readMissionCatalog, validateNativeExecution, type MissionCatalogClient } from "../missions/native-catalog"
import { MissionNotificationOutbox } from "../missions/notification-outbox"
import { MISSION_RPC_REJECTION, missionMutationStatus } from "../missions/rpc-errors"
import { readLocationRef } from "./compatibility/location"
import { readLocationRef, sameLocation } from "./compatibility/location"
import { taskExecutionModeSchema, taskContractReferenceWireSchema } from "../missions/native-wire-schema"
import { parseExecutionMode, parseMissionTaskMode, missionTaskModeSchema } from "../missions/task-execution-mode"
import { normalizeTaskDeclaration, taskContractReferenceSchema } from "../missions/task-declaration"
@ -240,10 +241,12 @@ export async function setupMissionsPlugin(context: MissionsPluginContext, transp
lifecycle: async (input, context) => {
assertActive()
const value = object(input)
if (!["start", "pause", "stop"].includes(String(value.action)) || !Number.isSafeInteger(value.expectedRevision) || Number(value.expectedRevision) < 1) throw new Error("Invalid mission control")
if (!["start", "pause", "stop"].includes(String(value.action)) || !Number.isSafeInteger(value.expectedRevision) || Number(value.expectedRevision) < 1
|| (value.graceful !== undefined && (value.graceful !== true || value.action !== "pause"))) throw new Error("Invalid mission control")
return mutationResult(context, () => control.lifecycle({
missionID: requiredText(value.missionID, "missionID", 100), requestID: requiredText(value.requestID, "requestID", 128),
action: value.action as "start" | "pause" | "stop", expectedRevision: Number(value.expectedRevision),
...(value.graceful === true ? { graceful: true as const } : {}),
}))
},
cleanupTarget: async input => {
@ -458,6 +461,43 @@ export async function setupMissionsBusiness(context: MissionsPluginContext,
})
registrations.push(tools)
// Ordinary native sub-agents of a one-time Mission are not actors and read no
// Mission context; a paused Mission still reaches them through their ancestry.
// Cached parent IDs only find the candidate actor cheaply (running Missions and
// unrelated families need no native read once warm). Placement can change with
// a native move, so a paused candidate's whole chain is re-read: every session
// must still link to its parent and share the actor's admitted project and Location.
const parents = new Map<string, string | null>()
const parentOf = async (sessionID: string) => {
let parent = parents.get(sessionID)
if (parent === undefined) {
const session = await context.session.get({ sessionID })
parent = session.id === sessionID ? session.parentID ?? null : null
if (parents.size >= MAX_PAUSE_ANCESTRY_CACHE) parents.delete(parents.keys().next().value!)
parents.set(sessionID, parent)
}
return parent
}
const pausedAncestorMission = async (control: MissionBusinessRoute["control"], sessionID: string): Promise<string | undefined> => {
const chain = [sessionID]
let mission: Awaited<ReturnType<typeof control.contextFor>>
while (!mission) {
if (chain.length > MAX_PAUSE_ANCESTRY) return undefined
const parent = await parentOf(chain.at(-1)!)
if (!parent || chain.includes(parent)) return undefined
chain.push(parent)
mission = await control.contextFor(parent)
}
if (!mission.paused) return undefined
const { projectID, location } = mission.placement
for (const [index, id] of chain.entries()) {
const session = await context.session.get({ sessionID: id })
if (session.id !== id || session.projectID !== projectID || !sameLocation(session.location, location)
|| (index < chain.length - 1 && session.parentID !== chain[index + 1])) return undefined
}
return mission.missionID
}
const contextHook = await context.session.hook("context", (event) => {
if (!active) return
return writer.run(async () => {
@ -470,10 +510,19 @@ export async function setupMissionsBusiness(context: MissionsPluginContext,
selected.current()
// One hinted membership read per turn: the instruction and the coordinator check share it.
const mission = await selected.control.contextFor(selected.readSessionID)
if (active && !mission && selected.readSessionID === event.sessionID) {
const paused = await pausedAncestorMission(selected.control, event.sessionID)
if (active && paused) {
selected.current()
event.system.push({ type: "text", text: missionPausedInstruction(paused, true) })
}
}
if (!active || !mission) return
selected.current()
if (selected.readSessionID !== event.sessionID) {
event.system.push({ type: "text", text: `You are a native descendant of coordinator ${selected.readSessionID}. Return normal scoped results to your immediate parent; only the coordinator declares, revises, reports or finalizes the business plan. mission.inspect is read-only.` })
// A paused mission's sub-agents stop after their current step too.
if (mission.paused) event.system.push({ type: "text", text: missionPausedInstruction(mission.missionID, true) })
for (const name of [...COORDINATOR_TOOLS, "report"]) delete event.tools[`mission_${name}`]
return
}
@ -496,6 +545,9 @@ export async function setupMissionsBusiness(context: MissionsPluginContext,
/** Topology and hand-off tools only the coordinator itself may see. */
const COORDINATOR_TOOLS = ["delegate", "revise", "briefing", "interrupt", "prepare"] as const
/** Native nesting levels walked to find a paused Mission actor, and sessions whose parent is remembered. */
const MAX_PAUSE_ANCESTRY = 32
const MAX_PAUSE_ANCESTRY_CACHE = 4096
const interruptSchema = {
type: "object",

View file

@ -260,6 +260,8 @@ const MAX_LISTED_INTERRUPTIONS = 32
async function interruptedConversations(client: Parameters<typeof readNativeControlFamily>[0], root: Parameters<typeof readNativeControlFamily>[1],
tasks: MissionSnapshot["missions"][number]["tasks"], paused: NonNullable<MissionLifecycleOperation["pausedDescendants"]>,
signal: AbortSignal, current: () => void): Promise<string> {
if (paused.graceful) return "\n\nThe mission was paused gracefully: you and your sub-agents were asked to finish the step in progress and stop."
+ " Delegated work may have stopped early. Read their last results, continue unfinished sub-agent conversations where they stopped rather than restarting them, and do not assume their work completed."
if (!paused.sessions.length && !paused.partial) return ""
const family = await readNativeControlFamily(client, root, signal, { assertCurrent: current, deadline: Date.now() + 15_000 })
const ids = [...family.members.values()].filter(({ session }) => session.id !== root.id && session.outcome === "interrupted"

View file

@ -151,11 +151,13 @@ export function registerMissionRoutes(app: FastifyInstance, deps: MissionRouteDe
})
app.post<{ Params: { id: string; missionID: string } }>("/api/workspaces/:id/missions/:missionID/control", async (request, reply) => {
const parsed = z.object({ action: z.enum(["start", "pause", "stop"]), expectedRevision: z.number().int().positive(), requestId: RequestID }).strict().safeParse(request.body)
const parsed = z.object({ action: z.enum(["start", "pause", "stop"]), expectedRevision: z.number().int().positive(), requestId: RequestID,
graceful: z.literal(true).optional() }).strict().refine(value => !value.graceful || value.action === "pause").safeParse(request.body)
const params = z.object({ id: z.string().trim().min(1).max(200), missionID: z.string().trim().min(1).max(100) }).safeParse(request.params)
if (!parsed.success || !params.success) return reply.code(400).send({ error: "Invalid mission control request" })
return mutate(request, reply, deps, params.data.id, async (rpc, options) => await rpc.lifecycle({
missionID: params.data.missionID, requestID: parsed.data.requestId, action: parsed.data.action, expectedRevision: parsed.data.expectedRevision,
...(parsed.data.graceful ? { graceful: true } : {}),
}, options) as { mission: MissionMap })
})

View file

@ -21,6 +21,7 @@ import { missionMark, type MissionPickerEntry } from "../../../../mission-picker
import { createDocumentAttention } from "../../../../session/session-idle-attention"
import { MissionSelectedActions } from "../../../../mission-selected-actions"
import type { MissionControlRetry } from "../../../../mission-lifecycle-controls"
import { missionActivityWorking } from "../../../../mission-native-execution-model"
import { MISSION_ROW_TITLE_MAX, missionDisplayTitle, missionRelativeTime } from "../../../../../lib/mission-display"
import { useI18n } from "../../../../../lib/i18n"
import { MissionCleanupPanel } from "../../../../mission-cleanup"
@ -387,6 +388,7 @@ const MissionControl: Component<MissionControlProps> = (props) => {
reading={isReading({ missionId: id, kind: "overview" })} onToggleReader={() => void read({ missionId: id, kind: "overview" })}
coordinatorActivity={state().activity?.missions.find(item => item.missionId === id)?.actors
.find(actor => actor.sessionId === selected().coordinatorSessionId)?.state}
working={missionActivityWorking(state().activity?.missions.find(item => item.missionId === id)?.actors)}
onOpenCoordinator={() => void openActor(selected().coordinatorSessionId)}
interrupted={missionEntries().find(entry => entry.key === `mission:${id}`)?.mark === "interrupted"}
onAdmitted={refreshVisible} registerRetry={registerRetry}

View file

@ -17,6 +17,8 @@ function primaryIcon(key?: string): JSX.Element {
export function MissionActionBar(props: {
label: string; reading: boolean; onToggleReader: () => void
primary?: MissionPrimaryAction; stop?: MissionPrimaryAction; request?: MissionPrimaryAction; onOpenConversation?: () => void
/** Interrupts a graceful Pause's still-running steps at once. */
interrupt?: MissionPrimaryAction
onEdit?: () => void; onDelete?: () => void; editDisabled?: boolean; deleteDisabled?: boolean
runNow?: MissionPrimaryAction; secondary?: JSX.Element; feedback?: JSX.Element
}) {
@ -53,8 +55,12 @@ export function MissionActionBar(props: {
<button type="button" class="mission-control-icon-button" aria-label={t("missionsPanel.picker.delete")} title={t("missionsPanel.picker.delete")}
disabled={!props.onDelete || props.deleteDisabled} onClick={() => props.onDelete?.()}><Trash2 class="h-4 w-4" aria-hidden="true" /></button>
</div>
<Show when={props.request || props.secondary}>
<Show when={props.interrupt || props.request || props.secondary}>
<div class="mission-action-texts">
<Show when={props.interrupt}>{interrupt =>
<button type="button" class="window-text-button" title={interrupt().ariaLabel}
disabled={interrupt().disabled} onClick={() => void interrupt().onSelect()}>{interrupt().label}</button>}
</Show>
<Show when={props.request}>{request =>
<button type="button" class="window-text-button mission-action-request" title={request().ariaLabel}
disabled={request().disabled} onClick={() => void request().onSelect()}>{request().label}</button>}

View file

@ -20,7 +20,9 @@ export interface MissionControlRetry { pending: () => boolean; reconcile: (missi
/** Play/Pause/Stop for the selected Mission: one contextual primary action and
* Stop. Unresolved requests are checked, and resent only on explicit refresh. */
export function createMissionLifecycle(props: { instanceId: string; mission: MissionMap; disabled?: boolean }) {
export function createMissionLifecycle(props: { instanceId: string; mission: MissionMap; disabled?: boolean
/** Observed activity: some actor or its sub-agents still runs. */
working?: boolean }) {
const { t } = useI18n()
const [refreshing, setRefreshing] = createSignal(false)
const location = () => {
@ -42,8 +44,12 @@ export function createMissionLifecycle(props: { instanceId: string; mission: Mis
const unresolved = () => pending() || Boolean(retry())
const durableRequest = (): MissionLifecycleInput | undefined => {
const operation = props.mission.control
return operation?.pending.length ? { action: operation.action, expectedRevision: operation.expectedRevision, requestId: operation.requestID } : undefined
return operation?.pending.length ? { action: operation.action, expectedRevision: operation.expectedRevision, requestId: operation.requestID,
...(operation.graceful ? { graceful: true as const } : {}) } : undefined
}
/** A graceful Pause whose running steps are still finishing. */
const pausing = () => !terminal() && state() === "paused" && props.mission.control?.action === "pause"
&& Boolean(props.mission.control.graceful) && Boolean(props.working)
const retryBlocked = () => {
const request = durableRequest() ?? retry()?.input
return !request || !missionLifecycleIntents.canReserve(identity(), props.mission.id, request)
@ -57,14 +63,14 @@ export function createMissionLifecycle(props: { instanceId: string; mission: Mis
try { await missionStore.refresh(props.instanceId) }
finally { if (alive && scope === identity() && startedEpoch === epoch) setRefreshing(false) }
}
async function act(action: "start" | "pause" | "stop", replay = false) {
async function act(action: "start" | "pause" | "stop", replay = false, graceful = false) {
if (props.disabled || busy() || !active() || (!replay && retry() && !pending()) || (replay && !pending() && !retry())) return
const instanceId = props.instanceId, scope = identity(), sourceLocation = location(), startedEpoch = epoch
const owned = () => alive && identity() === scope && epoch === startedEpoch
const mission = props.mission
if (!replay && full()) return
const input = replay ? durableRequest() ?? retry()?.input
: { action, expectedRevision: mission.revision, requestId: crypto.randomUUID() }
: { action, expectedRevision: mission.revision, requestId: crypto.randomUUID(), ...(graceful ? { graceful: true as const } : {}) }
if (!input) return
const intent = missionLifecycleIntents.reserve(scope, mission.id, input)
if (!intent || !missionLifecycleIntents.start(intent)) return
@ -114,8 +120,12 @@ export function createMissionLifecycle(props: { instanceId: string; mission: Mis
const disabled = blocked() || pending()
if (state() === "prepared") return { key: "start", label: t("missionsPanel.action.start"), ariaLabel: t("missions.control.run.start"), disabled, onSelect: () => act("start") }
if (state() === "paused") return { key: "resume", label: t("missionsPanel.action.resume"), ariaLabel: t("missions.control.run.resume"), disabled, onSelect: () => act("start") }
return { key: "pause", label: t("missionsPanel.action.pause"), ariaLabel: t("missions.control.run.pause"), disabled, onSelect: () => act("pause") }
// Pause lets running steps finish; Interrupt now stops them at once.
return { key: "pause", label: t("missionsPanel.action.pause"), ariaLabel: t("missions.control.run.pause"), disabled, onSelect: () => act("pause", false, true) }
}
const interruptNow = (): MissionPrimaryAction | undefined => pausing() && !unresolved()
? { key: "interrupt", label: t("missionsPanel.action.interruptNow"), ariaLabel: t("missions.control.run.interruptNow"),
disabled: blocked(), onSelect: () => act("pause") } : undefined
const stop = (): MissionPrimaryAction | undefined => !terminal() && !(retry() && !pending())
? { key: "stop", label: t("missions.control.run.stop"), disabled: blocked(), onSelect: confirmStop } : undefined
/** The unresolved exact request may be resent, unchanged, on an explicit click only. */
@ -134,6 +144,7 @@ export function createMissionLifecycle(props: { instanceId: string; mission: Mis
const resend = () => retryable() ? act(props.mission.control?.action ?? retry()?.input.action ?? "start", true) : Promise.resolve()
const feedback = <>
<Show when={busy()}><small role="status">{t("missions.control.mutation.pending")}</small></Show>
<Show when={!busy() && !unresolved() && pausing()}><small role="status">{t("missions.control.run.pausing")}</small></Show>
<Show when={!busy() && !unresolved() && partialInterrupt(props.mission)}>
<small role="status">{t("missions.control.run.partialInterrupt")}</small>
</Show>
@ -142,5 +153,5 @@ export function createMissionLifecycle(props: { instanceId: string; mission: Mis
<small role="alert">{t(value().reason === "rejected" ? `missions.control.run.rejected.${value().action}` : "missions.control.run.notSent.stop")}</small>}
</Show>
</>
return { primary, stop, retryable, reconcile, resend, feedback }
return { primary, stop, interruptNow, retryable, reconcile, resend, feedback }
}

View file

@ -50,6 +50,13 @@ export function missionReportNotificationKey(report: MissionReportDelivery): str
/** The latest Pause/Stop could not confirm that every native sub-agent stopped
* (incomplete inventory or a session still observed active). Older receipts
* without a family summary claim nothing either way. */
/** Whether observed activity shows work an immediate interrupt could still stop.
* A Form or permission anywhere in a family outranks its running members in the
* projection, so a waiting actor counts as working too. */
export function missionActivityWorking(actors: readonly Pick<MissionActorActivity, "state">[] | undefined): boolean {
return Boolean(actors?.some(actor => ["running", "queued", "background", "permission", "form"].includes(actor.state)))
}
export function partialInterrupt(mission: Pick<MissionMap, "control">): boolean {
const control = mission.control
if (!control || control.action === "start") return false

View file

@ -18,6 +18,8 @@ export function MissionSelectedActions(props: {
instanceId: string; mission: MissionMap; active: boolean; disabled: boolean; messagingDisabled: boolean
reading: boolean; onToggleReader: () => void
coordinatorActivity?: MissionActorActivityState; onOpenCoordinator: () => void; onAdmitted: () => Promise<void>
/** Observed activity: some actor or its sub-agents still runs. */
working?: boolean
/** The Mission's mark is interrupted: its coordinator stopped without a report while nothing else runs. */
interrupted: boolean
onEdit: () => void; onDelete: () => void; editDisabled: boolean; deleteDisabled: boolean
@ -25,7 +27,7 @@ export function MissionSelectedActions(props: {
}) {
const { t } = useI18n()
const lifecycle = createMissionLifecycle({ get instanceId() { return props.instanceId }, get mission() { return props.mission },
get disabled() { return props.disabled } })
get disabled() { return props.disabled }, get working() { return props.working } })
const briefing = createMissionBriefingRequest({ get instanceId() { return props.instanceId }, get mission() { return props.mission },
get active() { return props.active }, get disabled() { return props.messagingDisabled } })
const recovery = createMissionRecoveryAction({ get instanceId() { return props.instanceId }, get mission() { return props.mission },
@ -54,7 +56,7 @@ export function MissionSelectedActions(props: {
ariaLabel: briefing.waiting() ? t(`missions.briefing.request.${briefing.state()}`) : undefined,
disabled: briefing.waiting() || props.messagingDisabled || !props.active, onSelect: () => briefing.ask() } : undefined
return <MissionActionBar label={t("missionsPanel.picker.actions")} reading={props.reading} onToggleReader={props.onToggleReader}
primary={primary()} stop={lifecycle.stop()} request={request()} onOpenConversation={() => props.onOpenCoordinator()}
primary={primary()} stop={lifecycle.stop()} interrupt={lifecycle.interruptNow()} request={request()} onOpenConversation={() => props.onOpenCoordinator()}
onEdit={() => props.onEdit()} onDelete={() => props.onDelete()} editDisabled={props.editDisabled} deleteDisabled={props.deleteDisabled}
secondary={<MissionBriefSave disabled={props.disabled} active={() => props.active}
draft={() => missionRecordedBrief(props.mission)} initialName={() => missionDisplayTitle(props.mission, 80)} />}

View file

@ -79,7 +79,7 @@ import { attachEventSourceHandlers } from "./event-source-handlers"
import { HttpResponseError, retryFileSearch } from "./retryable-file-search"
import { deleteMissionRequest, type MissionDeletionRequest } from "./mission-cleanup"
import { missionMutationRequest } from "./mission-mutation"
import { missionLifecycleRequest } from "./mission-lifecycle-request"
import { missionLifecycleRequest, type MissionLifecycleInput } from "./mission-lifecycle-request"
import { authenticatedFetch } from "./auth-recovery"
import { CODENOMAD_API_BASE as API_BASE } from "./api-base"
import { backgroundReads } from "./background-read-queue"
@ -781,7 +781,7 @@ export const serverApi = {
const path = `/api/workspaces/${encodeURIComponent(instanceId)}/missions/${encodeURIComponent(missionId)}`
return deleteMissionRequest(API_BASE ? new URL(path, API_BASE).toString() : path, input)
},
controlMission(instanceId: string, missionId: string, input: { action: "start" | "pause" | "stop"; expectedRevision: number; requestId: string }): Promise<{ mission: MissionMap }> {
controlMission(instanceId: string, missionId: string, input: MissionLifecycleInput): Promise<{ mission: MissionMap }> {
const path = `/api/workspaces/${encodeURIComponent(instanceId)}/missions/${encodeURIComponent(missionId)}/control`
return missionLifecycleRequest(API_BASE ? new URL(path, API_BASE).toString() : path, input)
},

View file

@ -6,6 +6,7 @@ export const missionsPanelMessages = {
"missionsPanel.needs.permission": "Berechtigung angefragt von {actor}",
"missionsPanel.action.start": "Starten",
"missionsPanel.action.pause": "Pausieren",
"missionsPanel.action.interruptNow": "Jetzt unterbrechen",
"missionsPanel.action.resume": "Fortsetzen",
"missionsPanel.action.runNow": "Jetzt ausführen",
"missionsPanel.action.checkPassage": "Durchlauf prüfen",

View file

@ -283,6 +283,8 @@ export const missionMessages = {
"missions.control.run.rejected.stop": "Der Stopp wurde nicht angewendet: Die Mission hat sich zuvor geändert und läuft möglicherweise noch. Nichts wurde erneut gesendet; prüfen Sie sie und versuchen Sie es erneut.",
"missions.control.run.notSent.stop": "Die Mission hat sich während der Bestätigung geändert. Der Stopp wurde nicht gesendet; prüfen Sie sie und bestätigen Sie erneut.",
"missions.control.run.partialInterrupt": "Einige Sub-Agenten laufen möglicherweise noch.",
"missions.control.run.pausing": "Wird pausiert: Laufende Schritte werden abgeschlossen.",
"missions.control.run.interruptNow": "Laufende Unterhaltungen der Mission jetzt unterbrechen",
"missions.control.openCoordinator": "Koordinator öffnen",
"missions.control.mutation.error": "Die Änderung konnte nicht gespeichert werden. Ihre Eingabe wurde behalten.",
"missions.control.mutation.conflict": "Diese Mission hat sich geändert. Schließen Sie den Editor und öffnen Sie ihn erneut, um die neueste Version zu bearbeiten.",

View file

@ -6,6 +6,7 @@ export const missionsPanelMessages = {
"missionsPanel.needs.permission": "Permission requested by {actor}",
"missionsPanel.action.start": "Start",
"missionsPanel.action.pause": "Pause",
"missionsPanel.action.interruptNow": "Interrupt now",
"missionsPanel.action.resume": "Resume",
"missionsPanel.action.runNow": "Run now",
"missionsPanel.action.checkPassage": "Check passage",

View file

@ -287,6 +287,8 @@ export const missionMessages = {
"missions.control.run.rejected.stop": "Stop was not applied: the mission changed first and may still be running. Nothing was resent; review it and try again.",
"missions.control.run.notSent.stop": "The mission changed while you were confirming. Stop was not sent; review it and confirm again.",
"missions.control.run.partialInterrupt": "Some sub-agents may still be running.",
"missions.control.run.pausing": "Pausing: the steps in progress are finishing.",
"missions.control.run.interruptNow": "Interrupt the mission's running conversations now",
"missions.control.openCoordinator": "Open coordinator",
"missions.control.mutation.error": "The change could not be saved. Your input has been kept.",
"missions.control.mutation.conflict": "This mission changed. Close the editor and reopen it to edit the latest version.",

View file

@ -6,6 +6,7 @@ export const missionsPanelMessages = {
"missionsPanel.needs.permission": "Permiso solicitado por {actor}",
"missionsPanel.action.start": "Iniciar",
"missionsPanel.action.pause": "Pausar",
"missionsPanel.action.interruptNow": "Interrumpir ahora",
"missionsPanel.action.resume": "Reanudar",
"missionsPanel.action.runNow": "Ejecutar ahora",
"missionsPanel.action.checkPassage": "Comprobar pasada",

View file

@ -283,6 +283,8 @@ export const missionMessages = {
"missions.control.run.rejected.stop": "La detención no se aplicó: la misión cambió antes y puede seguir en ejecución. No se reenvió nada; revísala e inténtalo de nuevo.",
"missions.control.run.notSent.stop": "La misión cambió mientras confirmabas. La detención no se envió; revísala y vuelve a confirmar.",
"missions.control.run.partialInterrupt": "Es posible que algunos subagentes sigan en ejecución.",
"missions.control.run.pausing": "Pausando: los pasos en curso están terminando.",
"missions.control.run.interruptNow": "Interrumpir ahora las conversaciones en curso de la misión",
"missions.control.openCoordinator": "Abrir coordinador",
"missions.control.mutation.error": "No se pudo guardar el cambio. Tu entrada se ha conservado.",
"missions.control.mutation.conflict": "Esta misión ha cambiado. Cierra el editor y vuelve a abrirlo para editar la última versión.",

View file

@ -6,6 +6,7 @@ export const missionsPanelMessages = {
"missionsPanel.needs.permission": "Autorisation demandée par {actor}",
"missionsPanel.action.start": "Démarrer",
"missionsPanel.action.pause": "Pause",
"missionsPanel.action.interruptNow": "Interrompre maintenant",
"missionsPanel.action.resume": "Reprendre",
"missionsPanel.action.runNow": "Lancer maintenant",
"missionsPanel.action.checkPassage": "Vérifier le passage",

View file

@ -283,6 +283,8 @@ export const missionMessages = {
"missions.control.run.rejected.stop": "L’arrêt n’a pas été appliqué : la mission a changé entre-temps et peut encore être en cours. Rien n’a été renvoyé ; vérifiez-la puis réessayez.",
"missions.control.run.notSent.stop": "La mission a changé pendant la confirmation. L’arrêt n’a pas été envoyé ; vérifiez-la puis confirmez à nouveau.",
"missions.control.run.partialInterrupt": "Certains sous-agents sont peut-être encore actifs.",
"missions.control.run.pausing": "Mise en pause : les étapes en cours se terminent.",
"missions.control.run.interruptNow": "Interrompre maintenant les conversations en cours de la mission",
"missions.control.openCoordinator": "Ouvrir le coordinateur",
"missions.control.mutation.error": "La modification n'a pas pu être enregistrée. Votre saisie a été conservée.",
"missions.control.mutation.conflict": "Cette mission a changé. Fermez l'éditeur et rouvrez-le pour modifier la dernière version.",

View file

@ -6,6 +6,7 @@ export const missionsPanelMessages = {
"missionsPanel.needs.permission": "בקשת הרשאה מאת {actor}",
"missionsPanel.action.start": "התחלה",
"missionsPanel.action.pause": "השהיה",
"missionsPanel.action.interruptNow": "הפסקה עכשיו",
"missionsPanel.action.resume": "המשך",
"missionsPanel.action.runNow": "הרצה עכשיו",
"missionsPanel.action.checkPassage": "בדיקת ההרצה",

View file

@ -283,6 +283,8 @@ export const missionMessages = {
"missions.control.run.rejected.stop": "העצירה לא הוחלה: המשימה השתנתה לפני כן וייתכן שהיא עדיין פועלת. דבר לא נשלח מחדש; בדקו אותה ונסו שוב.",
"missions.control.run.notSent.stop": "המשימה השתנתה בזמן האישור. העצירה לא נשלחה; בדקו אותה ואשרו שוב.",
"missions.control.run.partialInterrupt": "ייתכן שחלק מתתי-הסוכנים עדיין פועלים.",
"missions.control.run.pausing": "בהשהיה: השלבים הפעילים מסתיימים.",
"missions.control.run.interruptNow": "הפסקת השיחות הפעילות של המשימה עכשיו",
"missions.control.openCoordinator": "פתיחת המתאם",
"missions.control.mutation.error": "לא ניתן לשמור את השינוי. הקלט נשמר.",
"missions.control.mutation.conflict": "המשימה הזו השתנתה. יש לסגור את העורך ולפתוח אותו מחדש כדי לערוך את הגרסה העדכנית.",

View file

@ -6,6 +6,7 @@ export const missionsPanelMessages = {
"missionsPanel.needs.permission": "{actor} が許可を求めています",
"missionsPanel.action.start": "開始",
"missionsPanel.action.pause": "一時停止",
"missionsPanel.action.interruptNow": "今すぐ中断",
"missionsPanel.action.resume": "再開",
"missionsPanel.action.runNow": "今すぐ実行",
"missionsPanel.action.checkPassage": "実行を確認",

View file

@ -283,6 +283,8 @@ export const missionMessages = {
"missions.control.run.rejected.stop": "停止は適用されませんでした。先にミッションが変更され、まだ実行中の可能性があります。再送信はしていません。内容を確認してからもう一度お試しください。",
"missions.control.run.notSent.stop": "確認中にミッションが変更されました。停止は送信されていません。内容を確認してから再度確認してください。",
"missions.control.run.partialInterrupt": "一部のサブエージェントがまだ実行中の可能性があります。",
"missions.control.run.pausing": "一時停止中: 実行中のステップを完了しています。",
"missions.control.run.interruptNow": "ミッションの実行中の会話を今すぐ中断",
"missions.control.openCoordinator": "コーディネーターを開く",
"missions.control.mutation.error": "変更を保存できませんでした。入力内容は保持されています。",
"missions.control.mutation.conflict": "このミッションは変更されました。エディターを閉じて開き直し、最新バージョンを編集してください。",

View file

@ -6,6 +6,7 @@ export const missionsPanelMessages = {
"missionsPanel.needs.permission": "{actor} ले अनुमति माग्यो",
"missionsPanel.action.start": "सुरु गर्नुहोस्",
"missionsPanel.action.pause": "रोक्नुहोस्",
"missionsPanel.action.interruptNow": "अहिले नै रोक्नुहोस्",
"missionsPanel.action.resume": "पुनः सुरु गर्नुहोस्",
"missionsPanel.action.runNow": "अहिले चलाउनुहोस्",
"missionsPanel.action.checkPassage": "पालो जाँच गर्नुहोस्",

View file

@ -283,6 +283,8 @@ export const missionMessages = {
"missions.control.run.rejected.stop": "रोक लागू भएन: मिसन पहिले नै परिवर्तन भयो र अझै चलिरहेको हुन सक्छ। केही पनि पुनः पठाइएन; समीक्षा गरेर फेरि प्रयास गर्नुहोस्।",
"missions.control.run.notSent.stop": "पुष्टि गर्दै गर्दा मिसन परिवर्तन भयो। रोक पठाइएन; समीक्षा गरेर फेरि पुष्टि गर्नुहोस्।",
"missions.control.run.partialInterrupt": "केही उप-एजेन्टहरू अझै चलिरहेका हुन सक्छन्।",
"missions.control.run.pausing": "रोकिँदै: चलिरहेका चरणहरू सकिँदैछन्।",
"missions.control.run.interruptNow": "मिसनका चलिरहेका कुराकानीहरू अहिले नै रोक्नुहोस्",
"missions.control.openCoordinator": "समन्वयकर्ता खोल्नुहोस्",
"missions.control.mutation.error": "परिवर्तन सेभ गर्न सकिएन। तपाईंको इनपुट राखिएको छ।",
"missions.control.mutation.conflict": "यो मिसन परिवर्तन भयो। नवीनतम संस्करण सम्पादन गर्न सम्पादक बन्द गरेर फेरि खोल्नुहोस्।",

View file

@ -6,6 +6,7 @@ export const missionsPanelMessages = {
"missionsPanel.needs.permission": "{actor} запрашивает разрешение",
"missionsPanel.action.start": "Запустить",
"missionsPanel.action.pause": "Приостановить",
"missionsPanel.action.interruptNow": "Прервать сейчас",
"missionsPanel.action.resume": "Возобновить",
"missionsPanel.action.runNow": "Запустить сейчас",
"missionsPanel.action.checkPassage": "Проверить прогон",

View file

@ -283,6 +283,8 @@ export const missionMessages = {
"missions.control.run.rejected.stop": "Остановка не применена: миссия успела измениться и, возможно, ещё выполняется. Ничего не отправлено повторно; проверьте её и повторите попытку.",
"missions.control.run.notSent.stop": "Миссия изменилась во время подтверждения. Остановка не отправлена; проверьте её и подтвердите снова.",
"missions.control.run.partialInterrupt": "Некоторые субагенты, возможно, ещё работают.",
"missions.control.run.pausing": "Приостановка: текущие шаги завершаются.",
"missions.control.run.interruptNow": "Прервать текущие разговоры миссии сейчас",
"missions.control.openCoordinator": "Открыть координатор",
"missions.control.mutation.error": "Не удалось сохранить изменение. Ваш ввод сохранён.",
"missions.control.mutation.conflict": "Эта миссия изменилась. Закройте редактор и откройте его снова, чтобы править последнюю версию.",

View file

@ -6,6 +6,7 @@ export const missionsPanelMessages = {
"missionsPanel.needs.permission": "{actor} izin istiyor",
"missionsPanel.action.start": "Başlat",
"missionsPanel.action.pause": "Duraklat",
"missionsPanel.action.interruptNow": "Şimdi kes",
"missionsPanel.action.resume": "Sürdür",
"missionsPanel.action.runNow": "Şimdi çalıştır",
"missionsPanel.action.checkPassage": "Çalıştırmayı denetle",

View file

@ -283,6 +283,8 @@ export const missionMessages = {
"missions.control.run.rejected.stop": "Durdurma uygulanmadı: görev daha önce değişti ve hâlâ çalışıyor olabilir. Hiçbir şey yeniden gönderilmedi; gözden geçirip yeniden deneyin.",
"missions.control.run.notSent.stop": "Onaylarken görev değişti. Durdurma gönderilmedi; gözden geçirip yeniden onaylayın.",
"missions.control.run.partialInterrupt": "Bazı alt ajanlar hâlâ çalışıyor olabilir.",
"missions.control.run.pausing": "Duraklatılıyor: devam eden adımlar tamamlanıyor.",
"missions.control.run.interruptNow": "Görevin çalışan konuşmalarını şimdi kes",
"missions.control.openCoordinator": "Koordinatörü aç",
"missions.control.mutation.error": "Değişiklik kaydedilemedi. Girdiniz korundu.",
"missions.control.mutation.conflict": "Bu görev değişti. En son sürümü düzenlemek için düzenleyiciyi kapatıp yeniden açın.",

View file

@ -6,6 +6,7 @@ export const missionsPanelMessages = {
"missionsPanel.needs.permission": "{actor} 请求权限",
"missionsPanel.action.start": "开始",
"missionsPanel.action.pause": "暂停",
"missionsPanel.action.interruptNow": "立即中断",
"missionsPanel.action.resume": "继续",
"missionsPanel.action.runNow": "立即运行",
"missionsPanel.action.checkPassage": "检查本次运行",

View file

@ -283,6 +283,8 @@ export const missionMessages = {
"missions.control.run.rejected.stop": "停止未生效:任务已先发生变化,可能仍在运行。未重新发送;请检查后重试。",
"missions.control.run.notSent.stop": "确认期间任务发生了变化。停止未发送;请检查后再次确认。",
"missions.control.run.partialInterrupt": "部分子代理可能仍在运行。",
"missions.control.run.pausing": "正在暂停:进行中的步骤正在完成。",
"missions.control.run.interruptNow": "立即中断任务中正在运行的对话",
"missions.control.openCoordinator": "打开协调者",
"missions.control.mutation.error": "无法保存更改。您的输入已保留。",
"missions.control.mutation.conflict": "此任务已更改。请关闭编辑器并重新打开,以编辑最新版本。",

View file

@ -2,7 +2,8 @@ import type { MissionMap } from "../../../server/src/api-types"
import { authenticatedFetch } from "./auth-recovery"
import { HttpResponseError } from "./retryable-file-search"
export type MissionLifecycleInput = { action: "start" | "pause" | "stop"; expectedRevision: number; requestId: string }
/** `graceful` (Pause only) lets running steps finish instead of interrupting them. */
export type MissionLifecycleInput = { action: "start" | "pause" | "stop"; expectedRevision: number; requestId: string; graceful?: true }
const codes = ["revision-conflict", "request-conflict", "control-conflict", "control-pending"] as const
type LifecycleCode = typeof codes[number]

View file

@ -26,7 +26,7 @@ export interface MissionLifecycleRejection {
readonly requestId?: string
}
const sameInput = (a: MissionLifecycleInput, b: MissionLifecycleInput) =>
a.requestId === b.requestId && a.action === b.action && a.expectedRevision === b.expectedRevision
a.requestId === b.requestId && a.action === b.action && a.expectedRevision === b.expectedRevision && Boolean(a.graceful) === Boolean(b.graceful)
const key = (source: string, missionId: string, input: MissionLifecycleInput) => JSON.stringify([source, missionId, input.requestId])
export function createMissionLifecycleIntents(capacity = 64) {

View file

@ -931,7 +931,7 @@ test("one Play control starts and resumes; partial Pause survives remount and St
runState: input.action === "start" ? "running" : input.action === "pause" ? "paused" : "stopped",
status: input.action === "stop" ? "stopped" : "active",
control: { id: "evt_control", missionID: current.id, requestID: input.requestId, expectedRevision: input.expectedRevision, action: input.action,
targets: [{ sessionID: "ses_fixture", location: { directory: "/fixture" } }], pending: [] },
...(input.graceful ? { graceful: true } : {}), targets: [{ sessionID: "ses_fixture", location: { directory: "/fixture" } }], pending: [] },
}
if (input.action === "pause" && failPause) {
failPause = false

View file

@ -39,7 +39,7 @@ before(async () => {
after(() => runWithDiagnosticCleanup({ run: async () => { await browser?.close() }, diagnose: async () => {},
cleanup: async () => { await server?.close() }, onObservationError: () => {}, onCleanupError: error => console.error("revision fixture shutdown", error) }))
type Input = { action: "start" | "pause" | "stop"; expectedRevision: number; requestId: string }
type Input = { action: "start" | "pause" | "stop"; expectedRevision: number; requestId: string; graceful?: true }
function mission(id = "revision"): MissionMap {
return { version: 1, id, projectID: "fixture", projectCanonical: "/fixture", objective: `Lifecycle ${id}`, template: "custom",
coordinatorSessionId: "ses_fixture", status: "active", runState: "running", actors: [], frontier: [], claims: [], revision: 1,
@ -209,6 +209,7 @@ for (const action of ["pause", "stop"] as const) {
const input = route.request().postDataJSON() as Input; calls.push(input)
current = { ...current, revision: current.revision + 1, runState: action === "pause" ? "paused" : "stopped", status: action === "stop" ? "stopped" : "active",
control: { id: "evt_partial", missionID: current.id, requestID: input.requestId, expectedRevision: input.expectedRevision, action,
...(input.graceful ? { graceful: true } : {}),
targets: [{ sessionID: "ses_fixture", location: { directory: "/fixture" } }], pending: calls.length === 1 ? ["ses_fixture"] : [] } }
return calls.length === 1 ? route.fulfill({ status: 503, json: { code: "control-pending" } }) : route.fulfill({ json: { mission: current } })
})
@ -230,6 +231,46 @@ for (const action of ["pause", "stop"] as const) {
})
}
// A Form or permission anywhere in the family outranks its running members in the
// activity projection: waiting for the user must not hide Interrupt now.
for (const state of ["running", "form"] as const) test(`Pause lets ${state} work finish while it shows Pausing, and Interrupt now stops it at once`, async () => {
const page = await browser.newPage({ locale: "en-US" }), calls: Input[] = [], errors: string[] = []
page.on("pageerror", error => errors.push(error.message))
let current = mission(), working = true
try {
await page.route("**/api/**", route => route.fulfill({ json: {} }))
await page.route("**/api/workspaces/mission-visibility/missions**", route => {
if (route.request().method() === "GET") return route.fulfill({ json: { available: true, missions: [current], generatedAt: current.revision, discardedEvents: 0,
activity: { generatedAt: current.revision, missions: [{ missionId: current.id, actors: [{ sessionId: "ses_fixture", state: working ? state : "idle-without-report" }] }] } } })
const input = route.request().postDataJSON() as Input; calls.push(input)
current = { ...current, revision: current.revision + 1, runState: "paused",
control: { id: `evt_${calls.length}`, missionID: current.id, requestID: input.requestId, expectedRevision: input.expectedRevision, action: "pause",
...(input.graceful ? { graceful: true, targets: [] } : { targets: [{ sessionID: "ses_fixture", location: { directory: "/fixture" } }] }), pending: [] } }
return route.fulfill({ json: { mission: current } })
})
await open(page)
await pause(page).click()
const interrupt = page.getByRole("button", { name: "Interrupt now", exact: true })
await interrupt.waitFor()
assert.equal(await page.getByRole("status").filter({ hasText: "Pausing: the steps in progress are finishing." }).count(), 1)
assert.deepEqual(calls.map(call => [call.action, call.graceful]), [["pause", true]], "Pause interrupts nothing")
assert.equal(await page.getByRole("button", { name: "Resume mission", exact: true }).isEnabled(), true)
await interrupt.click()
await interrupt.waitFor({ state: "detached" })
assert.deepEqual(calls.map(call => [call.action, call.graceful]), [["pause", true], ["pause", undefined]], "Interrupt now is an immediate Pause")
assert.equal(await page.getByText("Pausing: the steps in progress are finishing.").count(), 0)
// Once nothing runs, a graceful Pause shows neither the status nor the action.
current = { ...mission(), revision: 5, runState: "paused", control: { id: "evt_quiet", missionID: "revision", requestID: "quiet", expectedRevision: 4,
action: "pause", graceful: true, targets: [], pending: [] } }
working = false
await missionRefresh(page).click(); await settled(page)
assert.equal(await interrupt.count(), 0)
assert.deepEqual(errors, [])
} finally { await page.close() }
})
test("Stop asks for confirmation in a dialog: cancel and Escape never submit; confirmation submits once", async () => {
const page = await browser.newPage({ locale: "en-US" }), calls: Input[] = []
let current = mission()