gestalt-mobile 0.40.0 → 0.41.1

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.
@@ -16,8 +16,8 @@ SPDX-License-Identifier: AGPL-3.0-or-later
16
16
  <link rel="icon" href="/icons/gestalt-mobile-192.png" />
17
17
  <link rel="apple-touch-icon" href="/icons/gestalt-mobile-180.png" />
18
18
  <title>Gestalt Mobile</title>
19
- <script type="module" crossorigin src="/assets/index-B07-pr2H.js"></script>
20
- <link rel="stylesheet" crossorigin href="/assets/index-CCALmXl1.css">
19
+ <script type="module" crossorigin src="/assets/index-G2PznC5F.js"></script>
20
+ <link rel="stylesheet" crossorigin href="/assets/index-D57mr3B1.css">
21
21
  </head>
22
22
  <body>
23
23
  <div id="app"></div>
@@ -56,7 +56,7 @@ export async function buildApp(deps) {
56
56
  registerFileRoutes(app, deps);
57
57
  registerGitRoutes(app, deps);
58
58
  registerSkillRoutes(app, deps);
59
- registerMaintenanceRoutes(app, deps);
59
+ registerMaintenanceRoutes(app);
60
60
  registerProblemHandler(app, Boolean(deps.staticDir));
61
61
  return app;
62
62
  }
@@ -789,13 +789,21 @@ describe('production composition', () => {
789
789
  beforeWriteDatabase.close();
790
790
  expect(beforeWrite.resolved_at).toBeNull();
791
791
  handle.responseSettled({ id: 811, outcome: 'resultWritten' });
792
- await expect(checkpointResponse).resolves.toEqual(toOrgPlanCheckpointToolResponse());
792
+ await expect(checkpointResponse).resolves.toEqual(toOrgPlanCheckpointToolResponse('recorded'));
793
+ const replayResponse = handle.request(l2CheckpointCall(812, threadId, active.active_turn_id, planIdentity));
794
+ await vi.waitFor(() => expect(handle.pendingResponses.has(812)).toBe(true));
795
+ handle.responseSettled({ id: 812, outcome: 'resultWritten' });
796
+ await expect(replayResponse).resolves.toEqual(toOrgPlanCheckpointToolResponse('alreadyRecorded'));
793
797
  const afterWriteDatabase = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
794
798
  const afterWrite = afterWriteDatabase
795
799
  .prepare('SELECT resolved_at FROM pending_interactions WHERE session_id = ? AND request_id = ?')
796
800
  .get(fixture.sessionId, '811');
801
+ const checkpointEvents = afterWriteDatabase
802
+ .prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'org-plan.step-checkpointed'")
803
+ .get(fixture.sessionId);
797
804
  afterWriteDatabase.close();
798
805
  expect(afterWrite.resolved_at).toEqual(expect.any(String));
806
+ expect(checkpointEvents.count).toBe(1);
799
807
  expect(timers).toHaveLength(schedulesBeforeCheckpoint);
800
808
  expect(coordinator.snapshot(fixture.sessionId).health).not.toMatchObject({
801
809
  phase: 'waitingForAgentEvent',
@@ -941,7 +949,7 @@ describe('production composition', () => {
941
949
  });
942
950
  const { handle, response } = await holdCheckpointRequest(fixture, timers, 812, true, true);
943
951
  handle.responseSettled({ id: 812, outcome: 'resultWritten' });
944
- await expect(response).resolves.toEqual(toOrgPlanCheckpointToolResponse());
952
+ await expect(response).resolves.toEqual(toOrgPlanCheckpointToolResponse('recorded'));
945
953
  const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
946
954
  const checkpoint = database
947
955
  .prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'org-plan.step-checkpointed'")
@@ -950,7 +958,7 @@ describe('production composition', () => {
950
958
  expect(checkpoint.count).toBe(1);
951
959
  await fixture.app.close();
952
960
  });
953
- it('leaves an unsupported checkpoint exception before persistence inert', async () => {
961
+ it('returns a typed failed checkpoint result when persistence never occurs', async () => {
954
962
  let coordinator;
955
963
  const timers = [];
956
964
  const fixture = await createProductionAutopilotFixture({
@@ -969,8 +977,10 @@ describe('production composition', () => {
969
977
  vi.spyOn(coordinator, 'checkpointAccepted').mockImplementation(() => {
970
978
  throw new Error('injected-before-checkpoint-persistence');
971
979
  });
972
- const { response, startsBefore, writersBefore } = await holdCheckpointRequest(fixture, timers, 995, false);
973
- await expect(response).rejects.toThrow('CODEX_SERVER_REQUEST_UNSUPPORTED');
980
+ const { handle, response, startsBefore, writersBefore } = await holdCheckpointRequest(fixture, timers, 995, false);
981
+ await vi.waitFor(() => expect(handle.pendingResponses.has(995)).toBe(true));
982
+ handle.responseSettled({ id: 995, outcome: 'resultWritten' });
983
+ await expect(response).resolves.toEqual(toOrgPlanCheckpointToolResponse('failed'));
974
984
  expect(coordinator.checkpointAccepted).not.toBe(original);
975
985
  expect(fixture.handles).toHaveLength(writersBefore);
976
986
  expect(fixture.handles
@@ -1139,7 +1149,7 @@ describe('production composition', () => {
1139
1149
  };
1140
1150
  if (scenario === 'resultWritten-before-timeout') {
1141
1151
  handle.responseSettled({ id: 992, outcome: 'resultWritten' });
1142
- await expect(response).resolves.toEqual(toOrgPlanCheckpointToolResponse());
1152
+ await expect(response).resolves.toEqual(toOrgPlanCheckpointToolResponse('recorded'));
1143
1153
  await new Promise((resolve) => setTimeout(resolve, 30));
1144
1154
  expect(fixture.handles).toHaveLength(writersBefore);
1145
1155
  expect(failures()).toBe(0);
@@ -1147,7 +1157,7 @@ describe('production composition', () => {
1147
1157
  else if (scenario === 'duplicate-settlement') {
1148
1158
  handle.responseSettled({ id: 992, outcome: 'resultWritten' });
1149
1159
  handle.responseSettled({ id: 992, outcome: 'resultWritten' });
1150
- await expect(response).resolves.toEqual(toOrgPlanCheckpointToolResponse());
1160
+ await expect(response).resolves.toEqual(toOrgPlanCheckpointToolResponse('recorded'));
1151
1161
  await new Promise((resolve) => setTimeout(resolve, 30));
1152
1162
  expect(fixture.handles).toHaveLength(writersBefore);
1153
1163
  expect(failures()).toBe(0);
@@ -1292,7 +1302,7 @@ describe('production composition', () => {
1292
1302
  expect(approvalTurn.statusCode).toBe(202);
1293
1303
  await approvalFixture.app.close();
1294
1304
  });
1295
- it('rejects a checkpoint delivered after its owning root final has closed', async () => {
1305
+ it('returns failed for a checkpoint delivered after its owning root final has closed', async () => {
1296
1306
  const timers = [];
1297
1307
  const fixture = await createProductionAutopilotFixture({
1298
1308
  autopilotSchedule: (callback) => {
@@ -1330,7 +1340,7 @@ describe('production composition', () => {
1330
1340
  const planIdentity = createHash('sha256')
1331
1341
  .update(join(fixture.workspacePath, 'autopilot.org'))
1332
1342
  .digest('hex');
1333
- await expect(handle.request(l2CheckpointCall(812, threadId, active.active_turn_id, planIdentity))).rejects.toThrow('CODEX_SERVER_REQUEST_UNSUPPORTED');
1343
+ await expect(handle.request(l2CheckpointCall(812, threadId, active.active_turn_id, planIdentity))).resolves.toEqual(toOrgPlanCheckpointToolResponse('failed'));
1334
1344
  expect(timers).toHaveLength(timersAfterFinal);
1335
1345
  expect(fixture.handles
1336
1346
  .flatMap((candidate) => candidate.requests)
@@ -2841,7 +2851,7 @@ describe('production composition', () => {
2841
2851
  'protected',
2842
2852
  ],
2843
2853
  ['POST', '/api/git/clone', '/api/git/clone', 'protected'],
2844
- ['POST', '/api/maintenance/update-restart', '/api/maintenance/update-restart', 'protected'],
2854
+ ['POST', '/api/maintenance/quit', '/api/maintenance/quit', 'protected'],
2845
2855
  ['GET', '/api/skills', '/api/skills', 'protected'],
2846
2856
  ['HEAD', '/api/skills', '/api/skills', 'protected'],
2847
2857
  ['GET', '/api/skill-profiles', '/api/skill-profiles', 'protected'],
@@ -3461,7 +3471,7 @@ describe('production composition', () => {
3461
3471
  });
3462
3472
  await second.close();
3463
3473
  });
3464
- it('closes an active Codex child during graceful relay shutdown', async () => {
3474
+ it('quits through graceful relay shutdown and closes an active Codex child', async () => {
3465
3475
  const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
3466
3476
  const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
3467
3477
  ownTemporaryPaths(root, dataDir);
@@ -3505,9 +3515,11 @@ describe('production composition', () => {
3505
3515
  url: '/api/sessions',
3506
3516
  payload: { workspaceId: workspace.id, profile: 'default', provider: 'codex' },
3507
3517
  });
3508
- await app.close();
3518
+ const quit = await app.inject({ method: 'POST', url: '/api/maintenance/quit' });
3519
+ expect(quit.statusCode).toBe(202);
3520
+ expect(quit.json()).toEqual({ accepted: true });
3509
3521
  // Model and skill catalogs run for bootstrap and session start; the active child closes with the relay.
3510
- expect(closed).toBe(5);
3522
+ await vi.waitFor(() => expect(closed).toBe(5));
3511
3523
  });
3512
3524
  it('reconciles an interaction cleared upstream as no longer pending', async () => {
3513
3525
  const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
@@ -66,7 +66,6 @@ import { deriveSessionStatus } from './features/sessions/session-status.js';
66
66
  import { AUTOPILOT_CONTINUATION_PROMPT, AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT, autopilotExecutorLaunchPrompt, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
67
67
  import { createRelyingPartyConfig } from './config.js';
68
68
  import { parseOrgPlanAttention } from '../shared/contracts/org-plan-attention.js';
69
- import { GestaltUpdateRestartScheduler } from './platform/process/gestalt-update-restart.js';
70
69
  import { autopilotWaitLeaseToolResponse, } from '../shared/contracts/autopilot-wait-lease.js';
71
70
  import { agentCapacityRecoveryToolResponse } from '../shared/contracts/agent-capacity-recovery.js';
72
71
  import { pwaIconUrl } from './features/pwa/register-routes.js';
@@ -742,7 +741,7 @@ export async function composeRelayApp(options) {
742
741
  !identity ||
743
742
  checkpoint.planIdentity !== identity ||
744
743
  !rootOwned)
745
- return false;
744
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse('failed')) === true);
746
745
  const valid = validOrgPlanCheckpoint({
747
746
  checkpoint,
748
747
  plan: retained,
@@ -750,17 +749,15 @@ export async function composeRelayApp(options) {
750
749
  rootOwned,
751
750
  hasActiveL1Writer: (position) => hasActiveL1Writer(activity.snapshot(sessionId, new Date().toISOString()), position),
752
751
  });
753
- if (!valid)
754
- return false;
755
- if (!session.activeTurnId)
756
- return false;
757
- let accepted = false;
752
+ if (!valid || !session.activeTurnId)
753
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse('failed')) === true);
754
+ let recordStatus = 'failed';
758
755
  try {
759
- accepted = autopilot.checkpointAccepted(sessionId, checkpoint, session.activeTurnId, new Date().toISOString());
756
+ recordStatus = autopilot.checkpointAccepted(sessionId, checkpoint, session.activeTurnId, new Date().toISOString());
760
757
  }
761
758
  catch {
762
759
  if (!autopilot.checkpointHandoffFailed(sessionId, session.activeTurnId))
763
- return false;
760
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse('failed')) === true);
764
761
  const interaction = {
765
762
  ...rawInteraction,
766
763
  turnId: session.activeTurnId,
@@ -770,8 +767,10 @@ export async function composeRelayApp(options) {
770
767
  failCheckpointHandoff(sessionId, rawInteraction.requestId, session.activeTurnId);
771
768
  return true;
772
769
  }
773
- if (!accepted)
774
- return false;
770
+ if (recordStatus === 'failed')
771
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse('failed')) === true);
772
+ if (recordStatus === 'alreadyRecorded')
773
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse('alreadyRecorded')) === true);
775
774
  const interaction = {
776
775
  ...rawInteraction,
777
776
  turnId: session.activeTurnId,
@@ -780,7 +779,7 @@ export async function composeRelayApp(options) {
780
779
  interactions.add(sessionId, interaction);
781
780
  armCheckpointHandoffDeadline(sessionId, rawInteraction.requestId, session.activeTurnId);
782
781
  try {
783
- runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse());
782
+ runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, toOrgPlanCheckpointToolResponse('recorded'));
784
783
  }
785
784
  catch {
786
785
  // The accepted hold remains owned by the bounded deadline.
@@ -1013,7 +1012,6 @@ export async function composeRelayApp(options) {
1013
1012
  };
1014
1013
  },
1015
1014
  },
1016
- maintenance: { updateRestart: new GestaltUpdateRestartScheduler() },
1017
1015
  skills: {
1018
1016
  workspaces,
1019
1017
  profiles: options.profiles,
@@ -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 }
@@ -13,6 +13,19 @@ export const blockingResumeConditions = {
13
13
  externalState: 'externalStateChanged',
14
14
  materialAmbiguity: 'userGuidance',
15
15
  };
16
+ export function executorAssignment(executor, state = 'resumable') {
17
+ const taskName = executor.taskPath.split('/').filter(Boolean).at(-1);
18
+ const identity = executorIdentity(executor.canonicalTaskName, executor.continuationGeneration);
19
+ return {
20
+ canonicalPosition: executor.canonicalPosition,
21
+ canonicalTaskName: executor.canonicalTaskName,
22
+ taskName: taskName || identity.taskName,
23
+ taskPath: executor.taskPath,
24
+ threadId: executor.threadId,
25
+ generation: identity.generation,
26
+ state,
27
+ };
28
+ }
16
29
  export function checkpointTarget(kind, l1Id, l2Id) {
17
30
  return kind === 'l2'
18
31
  ? JSON.stringify([kind, l1Id, l2Id])
@@ -153,6 +166,8 @@ export function parsePersistedSupervisedLifecycle(value) {
153
166
  const resumeFailures = executorValue.resumeFailures === undefined
154
167
  ? 0
155
168
  : nonNegativeInteger(executorValue.resumeFailures);
169
+ const assignmentValue = executorValue.assignment === undefined ? undefined : record(executorValue.assignment);
170
+ const assignment = assignmentValue ? parseExecutorAssignment(assignmentValue) : undefined;
156
171
  const replacementValue = executorValue.replacement === undefined ? undefined : record(executorValue.replacement);
157
172
  const replacementGeneration = replacementValue && nonNegativeInteger(replacementValue.generation);
158
173
  const replacementTaskName = replacementValue && boundedText(replacementValue.taskName);
@@ -160,6 +175,12 @@ export function parsePersistedSupervisedLifecycle(value) {
160
175
  const replacementCanonicalPosition = replacementValue && boundedText(replacementValue.canonicalPosition);
161
176
  const replacementPlanIdentity = replacementValue && boundedText(replacementValue.planIdentity);
162
177
  const replacementPlanFingerprint = replacementValue && boundedText(replacementValue.planFingerprint);
178
+ const replacementPreviousValue = replacementValue && record(replacementValue.previous);
179
+ const replacementPrevious = replacementPreviousValue
180
+ ? parseExecutorAssignment(replacementPreviousValue)
181
+ : undefined;
182
+ const replacementReason = replacementValue && stringValue(replacementValue.reason, ['explicitExecutorRejection']);
183
+ const replacementEvidence = replacementValue && boundedText(replacementValue.evidence);
163
184
  if (!canonicalPosition ||
164
185
  !canonicalTaskName ||
165
186
  !taskPath ||
@@ -170,14 +191,32 @@ export function parsePersistedSupervisedLifecycle(value) {
170
191
  continuationGeneration === null ||
171
192
  continuationCount === null ||
172
193
  resumeFailures === null ||
194
+ (executorValue.assignment !== undefined && !assignment) ||
173
195
  (replacementValue &&
174
196
  (!replacementGeneration ||
175
197
  !replacementTaskName ||
176
198
  !replacementCanonicalTaskName ||
177
199
  !replacementCanonicalPosition ||
178
200
  !replacementPlanIdentity ||
179
- !replacementPlanFingerprint)))
201
+ !replacementPlanFingerprint ||
202
+ (replacementValue.previous !== undefined && !replacementPrevious) ||
203
+ (replacementReason !== undefined && replacementReason !== 'explicitExecutorRejection') ||
204
+ (replacementValue.evidence !== undefined && !replacementEvidence))))
180
205
  return undefined;
206
+ if (replacementValue) {
207
+ try {
208
+ const identity = executorIdentity(replacementCanonicalTaskName, replacementGeneration);
209
+ if (identity.taskName !== replacementTaskName ||
210
+ identity.canonicalPosition !== replacementCanonicalPosition ||
211
+ (replacementPrevious &&
212
+ (replacementPrevious.canonicalTaskName !== canonicalTaskName ||
213
+ replacementPrevious.canonicalPosition !== canonicalPosition)))
214
+ return undefined;
215
+ }
216
+ catch {
217
+ return undefined;
218
+ }
219
+ }
181
220
  const commandsValue = executorValue.commands;
182
221
  if (commandsValue !== undefined && (!Array.isArray(commandsValue) || commandsValue.length > 32))
183
222
  return undefined;
@@ -268,6 +307,7 @@ export function parsePersistedSupervisedLifecycle(value) {
268
307
  continuationGeneration,
269
308
  continuationCount,
270
309
  ...(resumeFailures ? { resumeFailures } : {}),
310
+ ...(assignment ? { assignment } : {}),
271
311
  ...(replacementValue
272
312
  ? {
273
313
  replacement: {
@@ -277,6 +317,9 @@ export function parsePersistedSupervisedLifecycle(value) {
277
317
  canonicalPosition: replacementCanonicalPosition,
278
318
  planIdentity: replacementPlanIdentity,
279
319
  planFingerprint: replacementPlanFingerprint,
320
+ ...(replacementPrevious ? { previous: replacementPrevious } : {}),
321
+ ...(replacementReason ? { reason: replacementReason } : {}),
322
+ ...(replacementEvidence ? { evidence: replacementEvidence } : {}),
280
323
  },
281
324
  }
282
325
  : {}),
@@ -396,19 +439,50 @@ function parseCheckpoints(value) {
396
439
  terminalReviewAccepted: value.terminalReviewAccepted,
397
440
  };
398
441
  }
442
+ function parseExecutorAssignment(value) {
443
+ const canonicalPosition = boundedText(value.canonicalPosition);
444
+ const canonicalTaskName = boundedText(value.canonicalTaskName);
445
+ const taskName = boundedText(value.taskName);
446
+ const taskPath = boundedText(value.taskPath);
447
+ const threadId = boundedText(value.threadId);
448
+ const generation = nonNegativeInteger(value.generation);
449
+ const state = stringValue(value.state, ['resumable', 'closed', 'unavailable']);
450
+ if (!canonicalPosition ||
451
+ !canonicalTaskName ||
452
+ !taskName ||
453
+ !taskPath ||
454
+ !threadId ||
455
+ generation === null ||
456
+ generation < 1 ||
457
+ !state)
458
+ return undefined;
459
+ try {
460
+ const identity = executorIdentity(canonicalTaskName, generation);
461
+ return identity.canonicalPosition === canonicalPosition && identity.taskName === taskName
462
+ ? { canonicalPosition, canonicalTaskName, taskName, taskPath, threadId, generation, state }
463
+ : undefined;
464
+ }
465
+ catch {
466
+ return undefined;
467
+ }
468
+ }
399
469
  export function classifyExecutorOutcome(input) {
400
470
  if (input.objectiveComplete)
401
- return { outcome: 'objective_complete' };
402
- if (input.reportedOutcome === 'cancelled' || input.reportedOutcome === 'failed')
403
- return { outcome: input.reportedOutcome };
471
+ return { classification: 'done', outcome: 'objective_complete' };
404
472
  const blocking = input.blockingReason && input.resumeCondition
405
473
  ? { reason: input.blockingReason, resumeCondition: input.resumeCondition }
406
474
  : undefined;
407
475
  if (input.reportedOutcome === 'blocked' && validStructuredBlock(blocking))
408
- return { outcome: 'blocked', blocking };
476
+ return { classification: 'blocker', outcome: 'blocked', blocking };
477
+ if (input.active)
478
+ // Fresh activity outranks the previous turn's terminal transport status.
479
+ return { classification: 'ongoing', outcome: 'partial' };
480
+ if (input.reportedOutcome === 'cancelled' || input.reportedOutcome === 'failed')
481
+ return { classification: 'recoverableFailure', outcome: input.reportedOutcome };
409
482
  // A turn ending, a checkpoint, or free-form language about time/context is
410
- // not objective state. Incomplete Org state remains mechanically partial.
411
- return { outcome: 'partial' };
483
+ // not objective state. Incomplete, settled Org state is mechanically
484
+ // recoverable and must return to the same canonical executor.
485
+ return { classification: 'recoverableFailure', outcome: 'partial' };
412
486
  }
413
487
  export function decideSupervisedLifecycle(input) {
414
488
  if (input.explicitlyPausedOrCancelled || executionComplete(input.plan))
@@ -0,0 +1,15 @@
1
+ /*
2
+ * Copyright (C) 2026 Dyne.org foundation
3
+ * Designed by Denis Roio <jaromil@dyne.org>
4
+ * SPDX-License-Identifier: AGPL-3.0-or-later
5
+ */
6
+ export function registerQuit(app) {
7
+ app.post('/api/maintenance/quit', async (_request, reply) => {
8
+ reply.raw.once('finish', () => {
9
+ void app.close().catch(() => {
10
+ app.log.error('Gestalt Mobile failed to quit cleanly');
11
+ });
12
+ });
13
+ return reply.code(202).send({ accepted: true });
14
+ });
15
+ }