gestalt-mobile 0.41.0 → 0.41.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.
@@ -7,6 +7,7 @@ import { createHash, randomBytes, randomUUID } from 'node:crypto';
7
7
  import { existsSync } from 'node:fs';
8
8
  import { homedir } from 'node:os';
9
9
  import { basename, dirname, join, resolve } from 'node:path';
10
+ import { debuglog } from 'node:util';
10
11
  import { buildApp } from './app.js';
11
12
  import { FilesystemWorkspaceCatalog } from './platform/catalog/filesystem-workspace-catalog.js';
12
13
  import { FilesystemWorkspaceFiles } from './platform/filesystem/filesystem-workspace-files.js';
@@ -48,7 +49,7 @@ import { toAgentActivityDto } from './features/agent-activity/activity-dto.js';
48
49
  import { decodeAgentActivityFacts } from './platform/codex/activity-facts.js';
49
50
  import { completedCommandId, compactOrgPlanCheckpointKind, isAgentCapacityRecoveryCall, isAutopilotWaitLeaseCall, resolvedServerRequestId, toPendingInteraction, } from './platform/codex/server-request.js';
50
51
  import { parseOrgPlanCheckpoint, toOrgPlanCheckpointToolResponse, } from '../shared/contracts/org-plan-checkpoint.js';
51
- import { resolveOrgPlanCheckpointSignal, validOrgPlanCheckpoint, } from './features/org-plan-checkpoint/application/validate.js';
52
+ import { hasActiveL1Writer, resolveOrgPlanCheckpointSignal, validateOrgPlanCheckpoint, } from './features/org-plan-checkpoint/application/validate.js';
52
53
  import { isValidInteractionResponse, isValidQuizInteractionResponse, } from './features/sessions/interaction/response-validator.js';
53
54
  import { promoteRecentThread } from './features/sessions/promote-recent-thread/use-case.js';
54
55
  import { FilesystemSkillProfileStore } from './platform/skills/filesystem-skill-profile-store.js';
@@ -65,11 +66,13 @@ import { AutopilotCoordinator } from './features/autopilot/application/service.j
65
66
  import { deriveSessionStatus } from './features/sessions/session-status.js';
66
67
  import { AUTOPILOT_CONTINUATION_PROMPT, AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT, autopilotExecutorLaunchPrompt, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
67
68
  import { createRelyingPartyConfig } from './config.js';
68
- import { parseOrgPlanAttention } from '../shared/contracts/org-plan-attention.js';
69
+ import { parseOrgPlanAttention, toOrgPlanAttentionAcknowledgement, } from '../shared/contracts/org-plan-attention.js';
69
70
  import { autopilotWaitLeaseToolResponse, } from '../shared/contracts/autopilot-wait-lease.js';
70
71
  import { agentCapacityRecoveryToolResponse } from '../shared/contracts/agent-capacity-recovery.js';
71
72
  import { pwaIconUrl } from './features/pwa/register-routes.js';
72
73
  const generatedProtocolVersion = 'codex-cli 0.144.3';
74
+ const checkpointDiagnostic = debuglog('gestalt-mobile:checkpoint');
75
+ const attentionDiagnostic = debuglog('gestalt-mobile:attention');
73
76
  export async function composeRelayApp(options) {
74
77
  const passkeyAuthEnabled = options.passkeyAuthEnabled ?? true;
75
78
  const relyingParty = createRelyingPartyConfig(options.relyingParty.publicOrigin);
@@ -104,6 +107,7 @@ export async function composeRelayApp(options) {
104
107
  const autopilotStore = new SqliteAutopilotStore(database);
105
108
  const attentionResolutionOperations = new Map();
106
109
  const checkpointHandoffTimers = new Map();
110
+ const attentionAcknowledgementTimers = new Map();
107
111
  const checkpointHandoffRecoveries = new Set();
108
112
  const supervisedPlans = new SupervisedPlanRegistry();
109
113
  const planStatusSource = new FilesystemPlanStatusSource(join(dirname(databasePath), 'plans'));
@@ -471,6 +475,9 @@ export async function composeRelayApp(options) {
471
475
  // request: the client can deliver them as a follow-up prompt instead.
472
476
  if (interaction.kind === 'quiz')
473
477
  continue;
478
+ if (interaction.kind === 'orgPlanAttention' &&
479
+ interactions.resolutionState(sessionId, interaction.requestId) === 'acknowledged')
480
+ continue;
474
481
  if (interactions.resolve(sessionId, interaction.requestId, occurredAt, outcome))
475
482
  publishInteractionResolved(sessionId, interaction.requestId, occurredAt, outcome);
476
483
  }
@@ -551,6 +558,36 @@ export async function composeRelayApp(options) {
551
558
  handle.unref?.();
552
559
  checkpointHandoffTimers.set(key, { handle, token });
553
560
  };
561
+ const cancelAttentionAcknowledgementTimer = (sessionId, requestId) => {
562
+ const key = checkpointTimerKey(sessionId, requestId);
563
+ const timer = attentionAcknowledgementTimers.get(key);
564
+ if (!timer)
565
+ return;
566
+ clearTimeout(timer.handle);
567
+ attentionAcknowledgementTimers.delete(key);
568
+ };
569
+ const armAttentionAcknowledgementDeadline = (sessionId, requestId) => {
570
+ cancelAttentionAcknowledgementTimer(sessionId, requestId);
571
+ const key = checkpointTimerKey(sessionId, requestId);
572
+ const token = Symbol(key);
573
+ const handle = setTimeout(() => {
574
+ if (attentionAcknowledgementTimers.get(key)?.token !== token)
575
+ return;
576
+ attentionAcknowledgementTimers.delete(key);
577
+ const ownedRuntime = runtime;
578
+ const session = sessions.find(sessionId);
579
+ if (!ownedRuntime || !session || !ownedRuntime.abandonServerRequest(sessionId, requestId))
580
+ return;
581
+ void ownedRuntime
582
+ .recycle(session, new Date().toISOString())
583
+ .then((recovered) => saveSession(recovered))
584
+ .catch(() => {
585
+ // The durable attention survives; normal session recovery can reacquire the writer.
586
+ });
587
+ }, options.attentionAcknowledgementDeadlineMs ?? 5_000);
588
+ handle.unref?.();
589
+ attentionAcknowledgementTimers.set(key, { handle, token });
590
+ };
554
591
  const failPendingCheckpointHandoffs = (sessionId, recoverBoundary) => {
555
592
  for (const interaction of interactions.list(sessionId)) {
556
593
  if (interaction.kind !== 'orgPlanCheckpoint')
@@ -568,28 +605,38 @@ export async function composeRelayApp(options) {
568
605
  * enters the durable store, requests human attention, and notifies autopilot.
569
606
  * Both the codex JSON-RPC handler and the kimi polling handler land here.
570
607
  */
571
- const acceptPendingInteraction = (sessionId, rawInteraction, session) => {
608
+ const acceptPendingInteraction = (sessionId, rawInteraction, session, acknowledgeAttention = false) => {
572
609
  const interaction = {
573
610
  ...rawInteraction,
574
611
  turnId: session.activeTurnId,
575
612
  requestedAt: new Date().toISOString(),
576
613
  };
577
- interactions.add(sessionId, interaction);
578
- activity.observe({
579
- sessionId,
580
- occurredAt: interaction.requestedAt,
581
- kind: 'interactionPending',
582
- ...(interaction.kind === 'orgPlanAttention'
583
- ? { attentionReason: interaction.payload.reason }
584
- : {}),
585
- });
586
- autopilot.semanticEvent(sessionId, 'interactionChanged');
587
- const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
588
- sessions.save(updated);
589
- events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
590
- if (interaction.kind === 'orgPlanAttention')
591
- events.publish(journal.append(sessionId, 'org-plan.attention-required', interaction, updated.updatedAt));
592
- autopilot.evaluate(sessionId);
614
+ interactions.add(sessionId, interaction, acknowledgeAttention && interaction.kind === 'orgPlanAttention' ? 'acknowledged' : 'active');
615
+ try {
616
+ activity.observe({
617
+ sessionId,
618
+ occurredAt: interaction.requestedAt,
619
+ kind: 'interactionPending',
620
+ ...(interaction.kind === 'orgPlanAttention'
621
+ ? { attentionReason: interaction.payload.reason }
622
+ : {}),
623
+ });
624
+ autopilot.semanticEvent(sessionId, 'interactionChanged');
625
+ const updated = RelaySession.rehydrate(session).requestInteraction(interaction, new Date().toISOString()).snapshot;
626
+ sessions.save(updated);
627
+ events.publish(journal.append(sessionId, 'interaction.requested', interaction, updated.updatedAt));
628
+ if (interaction.kind === 'orgPlanAttention')
629
+ events.publish(journal.append(sessionId, 'org-plan.attention-required', interaction, updated.updatedAt));
630
+ autopilot.evaluate(sessionId);
631
+ }
632
+ catch (error) {
633
+ if (!acknowledgeAttention || interaction.kind !== 'orgPlanAttention')
634
+ throw error;
635
+ attentionDiagnostic('%o', {
636
+ requestId: interaction.requestId,
637
+ reasonCode: 'postPersistenceProjectionFailed',
638
+ });
639
+ }
593
640
  return true;
594
641
  };
595
642
  const acceptPlanUpdate = (sessionId, update) => {
@@ -625,7 +672,9 @@ export async function composeRelayApp(options) {
625
672
  if (resolvedRequestId) {
626
673
  const interaction = interactions.find(sessionId, resolvedRequestId);
627
674
  const outcome = interaction?.kind === 'orgPlanAttention' ? 'failed' : 'dismissed';
628
- if (interaction?.kind !== 'quiz' &&
675
+ if (!(interaction?.kind === 'orgPlanAttention' &&
676
+ interactions.resolutionState(sessionId, resolvedRequestId) === 'acknowledged') &&
677
+ interaction?.kind !== 'quiz' &&
629
678
  interactions.resolve(sessionId, resolvedRequestId, occurredAt, outcome)) {
630
679
  if (outcome === 'failed')
631
680
  publishAttentionSettlement(sessionId, resolvedRequestId, occurredAt, outcome);
@@ -683,7 +732,7 @@ export async function composeRelayApp(options) {
683
732
  const retained = supervisedPlans.find(sessionId);
684
733
  const identity = supervisedPlans.identity(sessionId);
685
734
  const checkpoint = retained && identity
686
- ? resolveOrgPlanCheckpointSignal(compactCheckpointKind, retained, identity)
735
+ ? resolveOrgPlanCheckpointSignal(compactCheckpointKind, retained, identity, supervisedPlans.publicationReason(sessionId))
687
736
  : null;
688
737
  if (checkpoint)
689
738
  rawInteraction = {
@@ -741,25 +790,38 @@ export async function composeRelayApp(options) {
741
790
  !identity ||
742
791
  checkpoint.planIdentity !== identity ||
743
792
  !rootOwned)
744
- return false;
745
- const valid = validOrgPlanCheckpoint({
793
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, checkpointFailureResponse(!checkpoint
794
+ ? compactCheckpointKind === 'l1Accepted'
795
+ ? 'acceptedL1NotResolved'
796
+ : 'checkpointPayloadInvalid'
797
+ : !retained
798
+ ? 'planProjectionUnavailable'
799
+ : !identity || checkpoint.planIdentity !== identity
800
+ ? 'planIdentityMismatch'
801
+ : 'rootNotOwner')) === true);
802
+ const validation = validateOrgPlanCheckpoint({
746
803
  checkpoint,
747
804
  plan: retained,
748
805
  planIdentity: identity,
749
806
  rootOwned,
750
807
  hasActiveL1Writer: (position) => hasActiveL1Writer(activity.snapshot(sessionId, new Date().toISOString()), position),
751
808
  });
752
- if (!valid)
753
- return false;
754
- if (!session.activeTurnId)
755
- return false;
756
- let accepted = false;
809
+ if (!validation.valid || !session.activeTurnId)
810
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, checkpointFailureResponse(validation.valid ? 'rootTurnNotActive' : validation.reasonCode, {
811
+ ...(!validation.valid && validation.expected
812
+ ? { expected: validation.expected }
813
+ : {}),
814
+ ...(!validation.valid && validation.observed
815
+ ? { observed: validation.observed }
816
+ : {}),
817
+ })) === true);
818
+ let recordStatus = 'failed';
757
819
  try {
758
- accepted = autopilot.checkpointAccepted(sessionId, checkpoint, session.activeTurnId, new Date().toISOString());
820
+ recordStatus = autopilot.checkpointAccepted(sessionId, checkpoint, session.activeTurnId, new Date().toISOString());
759
821
  }
760
822
  catch {
761
823
  if (!autopilot.checkpointHandoffFailed(sessionId, session.activeTurnId))
762
- return false;
824
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, checkpointFailureResponse('checkpointPersistenceFailed')) === true);
763
825
  const interaction = {
764
826
  ...rawInteraction,
765
827
  turnId: session.activeTurnId,
@@ -769,8 +831,10 @@ export async function composeRelayApp(options) {
769
831
  failCheckpointHandoff(sessionId, rawInteraction.requestId, session.activeTurnId);
770
832
  return true;
771
833
  }
772
- if (!accepted)
773
- return false;
834
+ if (recordStatus === 'failed')
835
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, checkpointFailureResponse('checkpointStateConflict')) === true);
836
+ if (recordStatus === 'alreadyRecorded')
837
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse('alreadyRecorded')) === true);
774
838
  const interaction = {
775
839
  ...rawInteraction,
776
840
  turnId: session.activeTurnId,
@@ -779,7 +843,7 @@ export async function composeRelayApp(options) {
779
843
  interactions.add(sessionId, interaction);
780
844
  armCheckpointHandoffDeadline(sessionId, rawInteraction.requestId, session.activeTurnId);
781
845
  try {
782
- runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse());
846
+ runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse('recorded'));
783
847
  }
784
848
  catch {
785
849
  // The accepted hold remains owned by the bounded deadline.
@@ -788,6 +852,24 @@ export async function composeRelayApp(options) {
788
852
  // is cancelled solely by the JSON-RPC write-settlement callback.
789
853
  return true;
790
854
  }
855
+ if (rawInteraction.kind === 'orgPlanAttention') {
856
+ try {
857
+ acceptPendingInteraction(sessionId, rawInteraction, session, true);
858
+ const acknowledged = runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanAttentionAcknowledgement()) === true;
859
+ if (acknowledged)
860
+ armAttentionAcknowledgementDeadline(sessionId, rawInteraction.requestId);
861
+ return acknowledged;
862
+ }
863
+ catch {
864
+ const rejected = runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanAttentionAcknowledgement({
865
+ accepted: false,
866
+ reason: 'persistenceFailed',
867
+ })) === true;
868
+ if (rejected)
869
+ armAttentionAcknowledgementDeadline(sessionId, rawInteraction.requestId);
870
+ return rejected;
871
+ }
872
+ }
791
873
  return acceptPendingInteraction(sessionId, rawInteraction, session);
792
874
  }, (sessionId) => {
793
875
  activity.disconnected(sessionId, new Date().toISOString());
@@ -827,7 +909,18 @@ export async function composeRelayApp(options) {
827
909
  const handleKimiServerRequest = (sessionId, request) => {
828
910
  const rawInteraction = toPendingInteraction(request);
829
911
  const session = withPendingInteractions(sessions.find(sessionId));
830
- return Boolean(rawInteraction && session && acceptPendingInteraction(sessionId, rawInteraction, session));
912
+ if (!rawInteraction || !session)
913
+ return false;
914
+ if (rawInteraction.kind !== 'orgPlanAttention')
915
+ return acceptPendingInteraction(sessionId, rawInteraction, session);
916
+ try {
917
+ acceptPendingInteraction(sessionId, rawInteraction, session, true);
918
+ void kimiRuntime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanAttentionAcknowledgement());
919
+ }
920
+ catch {
921
+ void kimiRuntime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanAttentionAcknowledgement({ accepted: false, reason: 'persistenceFailed' }));
922
+ }
923
+ return true;
831
924
  };
832
925
  kimiRuntime = kimiManager
833
926
  ? new KimiSessionRuntime({
@@ -850,6 +943,10 @@ export async function composeRelayApp(options) {
850
943
  })
851
944
  : null;
852
945
  runtime?.onServerResponseSettled((sessionId, requestId, outcome) => {
946
+ if (attentionAcknowledgementTimers.has(checkpointTimerKey(sessionId, requestId))) {
947
+ cancelAttentionAcknowledgementTimer(sessionId, requestId);
948
+ return;
949
+ }
853
950
  const interaction = interactions.find(sessionId, requestId);
854
951
  if (interaction?.kind !== 'orgPlanCheckpoint')
855
952
  return;
@@ -1347,6 +1444,18 @@ export async function composeRelayApp(options) {
1347
1444
  return { kind: 'staleOperation' };
1348
1445
  if (claim === 'missing')
1349
1446
  return { kind: 'noActive' };
1447
+ const acknowledged = interactions.resolutionState(sessionId, requestId) === 'acknowledged';
1448
+ if (acknowledged) {
1449
+ if (!interactions.beginDelivery(sessionId, requestId, operationKey))
1450
+ return { kind: 'staleOperation' };
1451
+ const resolvedAt = new Date().toISOString();
1452
+ if (!interactions.settleOperation(sessionId, requestId, operationKey, resolvedAt, 'answered'))
1453
+ return { kind: 'staleOperation' };
1454
+ const accepted = { kind: 'accepted', resolvedAt };
1455
+ idempotency.put(scope, operationKey, 202, JSON.stringify({ kind: 'replayed', resolvedAt }));
1456
+ publishAttentionSettlement(sessionId, requestId, resolvedAt, 'answered');
1457
+ return accepted;
1458
+ }
1350
1459
  // A durable capability belongs to the session/thread, not the
1351
1460
  // relay process. A supported stopped writer is retryable;
1352
1461
  // missing capability identifies a pre-rollout legacy thread.
@@ -1468,6 +1577,14 @@ export async function composeRelayApp(options) {
1468
1577
  checkpointHandoffTimers.delete(key);
1469
1578
  }
1470
1579
  }
1580
+ for (const key of [...attentionAcknowledgementTimers.keys()]) {
1581
+ if (key.startsWith(`${session.id}:`)) {
1582
+ const timer = attentionAcknowledgementTimers.get(key);
1583
+ if (timer)
1584
+ clearTimeout(timer.handle);
1585
+ attentionAcknowledgementTimers.delete(key);
1586
+ }
1587
+ }
1471
1588
  // Relay shutdown only releases this process's writer. A typed attention
1472
1589
  // request remains a durable human-visible blocker for the next relay
1473
1590
  // instance; only an app-server-cleared request is a failed audit outcome.
@@ -1497,7 +1614,8 @@ function resolveStateDatabasePath(root, stateHome) {
1497
1614
  const legacyPath = legacyRelayStatePath(root, stateHome);
1498
1615
  return existsSync(legacyPath) ? legacyPath : currentPath;
1499
1616
  }
1500
- function hasActiveL1Writer(snapshot, position) {
1501
- return snapshot.subagents.some((child) => child.canonicalPosition === position &&
1502
- ['working', 'awaitingAgent', 'awaitingHuman'].includes(child.state));
1617
+ function checkpointFailureResponse(reasonCode, details = {}) {
1618
+ const correlationId = randomUUID();
1619
+ checkpointDiagnostic('%o', { correlationId, reasonCode, ...details });
1620
+ return toOrgPlanCheckpointToolResponse('failed', { reasonCode, correlationId, ...details });
1503
1621
  }
@@ -201,7 +201,10 @@ function childOutcome(status, action, previous) {
201
201
  function childState(status, action, previous = 'working') {
202
202
  if (status === 'error' || status === 'failed' || status === 'systemError' || status === 'errored')
203
203
  return 'blocked';
204
- if (status === 'completed' || status === 'idle' || action === 'close_agent')
204
+ if (status === 'completed' ||
205
+ status === 'idle' ||
206
+ status === 'interrupted' ||
207
+ action === 'close_agent')
205
208
  return 'idle';
206
209
  // `wait` belongs to the caller/root; it does not rewrite the child which may
207
210
  // still be working. A child can explicitly report its own waiting status.
@@ -229,7 +232,10 @@ function childReason(status, action) {
229
232
  return 'processExited';
230
233
  if (action === 'wait')
231
234
  return 'collaborationWait';
232
- if (status === 'completed' || status === 'idle' || action === 'close_agent')
235
+ if (status === 'completed' ||
236
+ status === 'idle' ||
237
+ status === 'interrupted' ||
238
+ action === 'close_agent')
233
239
  return 'turnCompleted';
234
240
  return 'turnActive';
235
241
  }
@@ -3,7 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
- export const AUTOPILOT_PROMPT_VERSION = 'v10';
6
+ export const AUTOPILOT_PROMPT_VERSION = 'v11';
7
7
  export const AUTOPILOT_CONTINUATION_PROMPT = 'Inspect the active supervised Org Plan. Refer to every L1 as L<a> and each nested L2 as L<a>.<b>, using one-based positions. Spawn exactly one executor per L1 and pass the exact literal task_name l<a>: l1 for L1, l2 for L2, and so on. Never create an L2-specific task name or append a title, role, nickname, plan name, or generated label. A validated DONE L2 and an accepted L1 are mandatory answer boundaries. The corresponding gestalt_org_plan_checkpoint call must be the last tool call of that root turn: immediately emit exactly one boundary final and end the turn, without followup_task, review, executor launch, or later milestone work. Roll up commentary, files, verification, and commands since the previous boundary into that one answer; all later milestone activity belongs to a new root turn. On this later Autopilot turn, resume the same executor after an L2 boundary or launch the next executor after an accepted-L1 boundary. Treat a status question as an interruption: answer briefly, then perform the applicable continuation in the same turn. Before sending any blocker response or yielding because progress cannot continue safely, call gestalt_org_plan_attention for the matching decision-table blocker; never rely on blocker prose as the signal. Do not call it for routine progress or a recoverable failure. Before yielding for known long work, call gestalt_autopilot_wait_lease version 2 with relevant observable wake conditions and a bounded maxWaitMs; it is one episode, so reassess before registering another in a later turn. Yield only when its response contains accepted:true; accepted:false means no continuation was registered and requires same-turn supervision. For GitHub PR CI, start an owned gh pr checks --watch process and lease processExited plus processResultAvailable while it runs. When the Autopilot probe is active, register the compatible wait lease, declare genuine attention, or immediately do actionable work; never acknowledge waiting in prose. Do not send a status-only response.';
8
8
  export const AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT = 'Continue the same assigned Org L1 from its durable state. A prior turn ending did not complete the objective. Consume any supplied process result and take the next legal L2 action. Whenever an L2 reaches DONE, return its concise structured evidence and end the executor turn so the root can publish that mandatory answer boundary. Also report at the L1 review boundary or through structured attention.';
9
9
  /** Builds a physical launch instruction while keeping durable L1 identity canonical. */
@@ -11,7 +11,7 @@ export function autopilotExecutorLaunchPrompt(identity) {
11
11
  const launch = `Launch task_name ${identity.taskName} for canonical ${identity.canonicalPosition}; retain the canonical label in status and review output.`;
12
12
  if (identity.generation === 1)
13
13
  return `${AUTOPILOT_CONTINUATION_PROMPT} ${launch}`;
14
- return `${AUTOPILOT_CONTINUATION_PROMPT} ${launch} This is replacement generation ${identity.generation}: the durable ${identity.canonicalTaskName} slot may remain reserved. Do not reuse that task_name or attempt to change an existing agent's model in place. Interrupt the previous physical executor if it is still running, then spawn ${identity.taskName} with agent_type worker and an explicit model selected by the supervisor. Transfer sole ${identity.canonicalPosition} ownership and continue from its durable Org Plan and worktree state.`;
14
+ return `${AUTOPILOT_CONTINUATION_PROMPT} ${launch} This exact replacement generation ${identity.generation} is durably authorized by Mobile; do not infer another generation from this prompt or the roster; the durable ${identity.canonicalTaskName} slot may remain reserved. Do not reuse that task_name or attempt to change an existing agent's model in place. Interrupt the previous physical executor if it is still running, then spawn ${identity.taskName} with agent_type worker and an explicit model selected by the supervisor. Transfer sole ${identity.canonicalPosition} ownership and continue from its durable Org Plan and worktree state.`;
15
15
  }
16
16
  export const defaultAutopilotPolicy = Object.freeze({
17
17
  quiescenceMs: 1_000,
@@ -7,7 +7,7 @@ import { deriveSessionStatus } from '../../sessions/session-status.js';
7
7
  import { createHash } from 'node:crypto';
8
8
  import { AUTOPILOT_WAIT_MAX_MS, AUTOPILOT_WAIT_MIN_MS, } from '../../../../shared/contracts/autopilot-wait-lease.js';
9
9
  import { autopilotSnapshot, disabledAutopilot, } from '../domain/autopilot-session.js';
10
- import { classifyExecutorOutcome, decideSupervisedLifecycle, executorIdentity, validStructuredBlock, checkpointTarget, } from '../domain/supervised-lifecycle.js';
10
+ import { classifyExecutorOutcome, decideSupervisedLifecycle, executorIdentity, validStructuredBlock, checkpointTarget, executorAssignment, } from '../domain/supervised-lifecycle.js';
11
11
  import { classifyAgentActivity, decideAutopilot, executionComplete, } from './policy.js';
12
12
  import { consumeCheckpointBoundary, consumeObsoleteWait, consumeObservableWake, consumeWaitDeadline, recordAutomaticContinuation, recoverSafetyPause, registerProactiveWait as registerProtocolWait, reportProbe, semanticProgressKey, startSupervisionProtocol, } from '../domain/supervision-protocol.js';
13
13
  export class AutopilotCoordinator {
@@ -213,7 +213,7 @@ export class AutopilotCoordinator {
213
213
  const activity = this.deps.activity(sessionId);
214
214
  if (!retained || activity?.confidence !== 'fresh')
215
215
  return false;
216
- const executor = this.currentExecutor(sessionId, retained.plan, state.consecutiveNoProgress, state.executor);
216
+ const executor = this.currentExecutor(sessionId, retained.plan, state.consecutiveNoProgress, state.executor, activity);
217
217
  return Boolean(executor && !this.authoritativeExecutorActive(state, retained.plan, activity));
218
218
  }
219
219
  /** Fails closed only for the currently active bounded probe. */
@@ -612,10 +612,10 @@ export class AutopilotCoordinator {
612
612
  const prior = this.deps.store.find(sessionId);
613
613
  const retained = this.deps.plan(sessionId);
614
614
  if (!prior || !retained || !turnId || checkpoint.planIdentity !== retained.identity)
615
- return false;
615
+ return 'failed';
616
616
  const previous = prior.checkpoints;
617
617
  if (previous && previous.planIdentity !== retained.identity)
618
- return false;
618
+ return 'failed';
619
619
  const reportedL1Ids = previous?.reportedL1Ids ?? [];
620
620
  const reportedL2Ids = previous?.reportedL2Ids ?? [];
621
621
  const canonicalPosition = checkpoint.kind === 'terminalReviewAccepted' ? 'terminal' : checkpoint.position;
@@ -631,12 +631,12 @@ export class AutopilotCoordinator {
631
631
  .digest('hex');
632
632
  const acceptedKeys = previous?.acceptedKeys ?? [];
633
633
  if (acceptedKeys.includes(key))
634
- return previous?.pendingTurnId === turnId && previous.pendingKind === checkpoint.kind;
634
+ return 'alreadyRecorded';
635
635
  const l2Key = checkpoint.kind === 'l2Completed' ? JSON.stringify([checkpoint.l1Id, checkpoint.l2Id]) : null;
636
636
  if (checkpoint.kind === 'terminalReviewAccepted' && previous?.terminalReviewAccepted)
637
- return false;
637
+ return 'alreadyRecorded';
638
638
  if (completionEpoch?.completed && !completionEpoch.reopened)
639
- return false;
639
+ return 'alreadyRecorded';
640
640
  const completionEpochs = [
641
641
  ...(previous?.completionEpochs ?? []).filter((entry) => entry.target !== target),
642
642
  { target, epoch, reopened: false, completed: true },
@@ -655,7 +655,19 @@ export class AutopilotCoordinator {
655
655
  checkpointHandoffFailed: false,
656
656
  terminalReviewAccepted: checkpoint.kind === 'terminalReviewAccepted' || previous?.terminalReviewAccepted === true,
657
657
  };
658
- const checkpointed = { ...prior, checkpoints, updatedAt: occurredAt };
658
+ const checkpointed = {
659
+ ...prior,
660
+ ...(checkpoint.kind === 'l1Accepted' && prior.executor?.assignment
661
+ ? {
662
+ executor: {
663
+ ...prior.executor,
664
+ assignment: { ...prior.executor.assignment, state: 'closed' },
665
+ },
666
+ }
667
+ : {}),
668
+ checkpoints,
669
+ updatedAt: occurredAt,
670
+ };
659
671
  this.persist({
660
672
  ...checkpointed,
661
673
  supervision: checkpointed.supervision
@@ -688,7 +700,7 @@ export class AutopilotCoordinator {
688
700
  this.cancelWaitTimer(sessionId);
689
701
  this.parkedSubscriptions.delete(sessionId);
690
702
  this.cancelTimer(sessionId);
691
- return true;
703
+ return 'recorded';
692
704
  }
693
705
  /**
694
706
  * Records a failed checkpoint transport handoff without releasing its root
@@ -852,6 +864,12 @@ export class AutopilotCoordinator {
852
864
  this.activityEventKeys.set(sessionId, eventKey);
853
865
  if (this.semanticEvent(sessionId, 'agentActivityChanged'))
854
866
  return;
867
+ // `executorChanged` is an edge subscription, not a request to wait for a
868
+ // later generic reconciliation pass. Consume it from the same fresh
869
+ // activity snapshot that exposed the settled canonical executor.
870
+ if (this.executorWakeAlreadySatisfied(sessionId, prior, ['executorChanged']) &&
871
+ this.semanticEvent(sessionId, 'executorChanged'))
872
+ return;
855
873
  const disposition = classifyAgentActivity(activity);
856
874
  if (disposition === 'attention') {
857
875
  this.evaluate(sessionId);
@@ -1283,23 +1301,36 @@ export class AutopilotCoordinator {
1283
1301
  l1.reviewStatus === 'REVIEWED' &&
1284
1302
  (!reportedL1.has(l1.id) || epochNeedsReport(checkpointTarget('l1', l1.id)))));
1285
1303
  }
1286
- currentExecutor(sessionId, plan, continuationCount, persisted) {
1304
+ currentExecutor(sessionId, plan, continuationCount, persisted, activity = this.deps.activity(sessionId)) {
1287
1305
  const stepIndex = plan.steps.findIndex((step) => step.id === plan.currentStepId || step.state === 'WIP');
1288
1306
  const index = stepIndex >= 0 ? stepIndex : plan.steps.findIndex((step) => step.state !== 'DONE');
1289
1307
  if (index < 0)
1290
1308
  return undefined;
1291
1309
  const step = plan.steps[index];
1292
1310
  const canonicalPosition = `L${index + 1}`;
1293
- const child = this.deps
1294
- .activity(sessionId)
1295
- ?.subagents.filter((candidate) => candidate.canonicalPosition === canonicalPosition)
1296
- .sort((left, right) => (right.continuationGeneration ?? 1) - (left.continuationGeneration ?? 1) ||
1297
- Date.parse(right.lastActivityAt) - Date.parse(left.lastActivityAt) ||
1298
- (left.threadId ?? left.id).localeCompare(right.threadId ?? right.id))[0];
1311
+ const candidates = activity?.subagents.filter((candidate) => candidate.canonicalPosition === canonicalPosition) ?? [];
1312
+ const assigned = persisted?.assignment?.canonicalPosition === canonicalPosition
1313
+ ? persisted.assignment
1314
+ : undefined;
1315
+ const replacementChild = persisted?.replacement
1316
+ ? candidates.find((candidate) => candidate.canonicalTaskName === persisted.replacement.canonicalTaskName &&
1317
+ (candidate.continuationGeneration ?? 1) === persisted.replacement.generation &&
1318
+ candidate.taskPath?.split('/').filter(Boolean).at(-1) ===
1319
+ persisted.replacement.taskName)
1320
+ : undefined;
1321
+ const child = replacementChild ??
1322
+ (assigned
1323
+ ? candidates.find((candidate) => candidate.canonicalTaskName === assigned.canonicalTaskName &&
1324
+ candidate.taskPath === assigned.taskPath &&
1325
+ (candidate.threadId ?? candidate.id) === assigned.threadId &&
1326
+ (candidate.continuationGeneration ?? 1) === assigned.generation)
1327
+ : candidates
1328
+ .filter((candidate) => candidate.canonicalPosition === canonicalPosition)
1329
+ .sort((left, right) => (right.continuationGeneration ?? 1) - (left.continuationGeneration ?? 1) ||
1330
+ Date.parse(right.lastActivityAt) - Date.parse(left.lastActivityAt) ||
1331
+ (left.threadId ?? left.id).localeCompare(right.threadId ?? right.id))[0]);
1299
1332
  if (!child) {
1300
- if (persisted?.canonicalPosition !== canonicalPosition ||
1301
- persisted.outcome === 'cancelled' ||
1302
- persisted.outcome === 'failed')
1333
+ if (persisted?.canonicalPosition !== canonicalPosition || persisted.outcome === 'cancelled')
1303
1334
  return undefined;
1304
1335
  return {
1305
1336
  ...persisted,
@@ -1308,16 +1339,20 @@ export class AutopilotCoordinator {
1308
1339
  ownedProcesses: refreshPersistedProcesses(persisted.ownedProcesses, [], this.deps.now()),
1309
1340
  };
1310
1341
  }
1311
- // A roster entry for a newer generation is authoritative even when that
1312
- // generation is disconnected. Never fall back to an older persisted
1313
- // writer; incomplete owner metadata instead fences all continuation.
1342
+ // Once adopted, a persisted assignment outranks every unrelated roster
1343
+ // entry. A new physical writer can enter only through the exact durable
1344
+ // replacement authorization above.
1314
1345
  if (!child.taskPath || !child.canonicalTaskName)
1315
1346
  return undefined;
1316
- if (child.outcome === 'cancelled' || child.outcome === 'failed')
1347
+ if (child.outcome === 'cancelled')
1317
1348
  return undefined;
1318
1349
  const activeL2 = step.children.find((candidate) => candidate.state === 'WIP');
1350
+ const active = child.state === 'working' ||
1351
+ child.state === 'awaitingAgent' ||
1352
+ Boolean(child.ownedProcesses?.some((process) => process.state === 'running' || process.state === 'detached-active'));
1319
1353
  const outcome = classifyExecutorOutcome({
1320
1354
  objectiveComplete: step.state === 'DONE',
1355
+ active,
1321
1356
  reportedOutcome: child.outcome,
1322
1357
  });
1323
1358
  return {
@@ -1346,6 +1381,15 @@ export class AutopilotCoordinator {
1346
1381
  ? { resumeFailures: persisted.resumeFailures }
1347
1382
  : {}),
1348
1383
  ...(persisted?.commands ? { commands: persisted.commands } : {}),
1384
+ assignment: replacementChild || !assigned
1385
+ ? executorAssignment({
1386
+ canonicalPosition,
1387
+ canonicalTaskName: child.canonicalTaskName,
1388
+ taskPath: child.taskPath,
1389
+ threadId: child.threadId ?? child.id,
1390
+ continuationGeneration: child.continuationGeneration ?? 1,
1391
+ })
1392
+ : assigned,
1349
1393
  };
1350
1394
  }
1351
1395
  /** A fresh roster may expose two physical writers for one canonical L1. */
@@ -1661,7 +1705,8 @@ export class AutopilotCoordinator {
1661
1705
  return conditions.some((condition) => this.semanticEvent(sessionId, condition));
1662
1706
  }
1663
1707
  freshExecutorIdentity(sessionId) {
1664
- const replacement = this.deps.store.find(sessionId)?.executor?.replacement;
1708
+ const state = this.deps.store.find(sessionId);
1709
+ const replacement = state?.executor?.replacement;
1665
1710
  if (replacement && this.validReplacement(sessionId, replacement))
1666
1711
  return replacement;
1667
1712
  const plan = this.deps.plan(sessionId)?.plan;
@@ -1672,11 +1717,22 @@ export class AutopilotCoordinator {
1672
1717
  if (index < 0)
1673
1718
  return undefined;
1674
1719
  const canonicalTaskName = `l${index + 1}`;
1675
- const generations = this.deps
1720
+ // A root boundary starts a root turn, not a replacement executor. The
1721
+ // existing physical owner remains resumable through ordinary L2, review,
1722
+ // and completion boundaries, regardless of its most recently observed
1723
+ // continuation generation. Only a durable replacement handoff above may
1724
+ // authorize a generated physical task name.
1725
+ if (state?.executor?.canonicalTaskName === canonicalTaskName &&
1726
+ state.executor.outcome !== 'cancelled')
1727
+ return undefined;
1728
+ // Legacy roster observations establish a resumable owner but never
1729
+ // authorize a generated name. Only scheduleExecutorReplacement creates
1730
+ // the exact durable replacement record consumed above.
1731
+ if (this.deps
1676
1732
  .activity(sessionId)
1677
- ?.subagents.filter((child) => child.canonicalTaskName === canonicalTaskName)
1678
- .map((child) => child.continuationGeneration ?? 1) ?? [];
1679
- return executorIdentity(canonicalTaskName, Math.max(0, ...generations) + 1);
1733
+ ?.subagents.some((child) => child.canonicalTaskName === canonicalTaskName))
1734
+ return undefined;
1735
+ return executorIdentity(canonicalTaskName, 1);
1680
1736
  }
1681
1737
  validReplacement(sessionId, replacement) {
1682
1738
  const retained = this.deps.plan(sessionId);
@@ -1693,16 +1749,24 @@ export class AutopilotCoordinator {
1693
1749
  const retained = this.deps.plan(sessionId);
1694
1750
  if (!current?.requestedEnabled || !retained || current.executor?.replacement)
1695
1751
  return;
1696
- const identity = executorIdentity(executor.canonicalTaskName, executor.continuationGeneration + 1);
1752
+ const identity = executorIdentity(executor.canonicalTaskName, (executor.assignment?.generation ?? executor.continuationGeneration) + 1);
1697
1753
  const now = this.deps.now();
1698
1754
  const controlId = this.deps.nextControlId(sessionId, current.generation);
1699
1755
  const replacement = {
1700
1756
  ...executor,
1701
1757
  outcome: 'failed',
1758
+ ...(executor.assignment
1759
+ ? { assignment: { ...executor.assignment, state: 'unavailable' } }
1760
+ : {}),
1702
1761
  replacement: {
1703
1762
  ...identity,
1704
1763
  planIdentity: retained.identity,
1705
1764
  planFingerprint: fingerprint(retained.plan),
1765
+ previous: executor.assignment
1766
+ ? { ...executor.assignment, state: 'unavailable' }
1767
+ : executorAssignment(executor, 'unavailable'),
1768
+ reason: 'explicitExecutorRejection',
1769
+ evidence: 'retry-limit-exhausted',
1706
1770
  },
1707
1771
  commands: executor.commands?.map((command) => ['scheduled', 'issued'].includes(command.status)
1708
1772
  ? { ...command, status: 'superseded', updatedAt: now }