gestalt-mobile 0.35.3 → 0.35.5

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.
@@ -152,13 +152,13 @@ const autopilotPlanText = (childState = 'TODO') => `#+TITLE: Autopilot fixture
152
152
  const completedAutopilotPlanText = () => autopilotPlanText('DONE')
153
153
  .replace('* WIP [#A] Parent', '* DONE [#A] Parent')
154
154
  .replace(':REVIEW_STATUS: UNREVIEWED', ':REVIEW_STATUS: REVIEWED');
155
- async function installAutopilotPlan(app, sessionId, workspacePath, name, childState = 'TODO') {
155
+ async function installAutopilotPlan(app, sessionId, workspacePath, name, childState = 'TODO', reason = 'supervision-start') {
156
156
  const planPath = join(workspacePath, `${name}.org`);
157
157
  await writeFile(planPath, autopilotPlanText(childState));
158
158
  await writeFile(planStatusFilePath(planStatusDirectoryPath(workspacePath, sessionId), planPath), JSON.stringify({
159
159
  schemaVersion: 1,
160
160
  planPath,
161
- reason: 'supervision-start',
161
+ reason,
162
162
  updatedAt: new Date().toISOString(),
163
163
  }));
164
164
  await expect
@@ -2242,6 +2242,37 @@ describe('production composition', () => {
2242
2242
  });
2243
2243
  });
2244
2244
  describeCompositionConcern('lifecycle', () => {
2245
+ it('retains an authored plan without starting supervision', async () => {
2246
+ const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
2247
+ const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
2248
+ ownTemporaryPaths(root, dataDir);
2249
+ const workspacePath = join(root, 'workspace');
2250
+ await mkdir(workspacePath);
2251
+ const handles = [];
2252
+ const app = await composeAuthorizedApp({
2253
+ root,
2254
+ dataDir,
2255
+ relyingParty,
2256
+ installedCodexVersion: 'codex-cli 0.144.3',
2257
+ startAppServers: true,
2258
+ launchAppServer: liveAppServer(handles),
2259
+ profiles: {
2260
+ list: async () => [],
2261
+ require: async () => ({
2262
+ name: 'default',
2263
+ state: 'ok',
2264
+ status: 'ready',
2265
+ }),
2266
+ },
2267
+ });
2268
+ const sessionId = await createComposedSession(app);
2269
+ await installAutopilotPlan(app, sessionId, workspacePath, 'authored-only', 'TODO', 'authoring-start');
2270
+ expect((await app.inject(`/api/sessions/${sessionId}`)).json()).toMatchObject({
2271
+ autopilot: { enabled: false, state: 'disabled' },
2272
+ });
2273
+ expect(handles.flatMap((handle) => handle.requests.filter((request) => request.method === 'turn/start'))).toHaveLength(0);
2274
+ await app.close();
2275
+ });
2245
2276
  it('rejects a wait lease when automatic continuation is unavailable', async () => {
2246
2277
  const root = await mkdtemp(join(tmpdir(), 'gestalt-mobile-root-'));
2247
2278
  const dataDir = await mkdtemp(join(tmpdir(), 'gestalt-mobile-state-'));
@@ -588,7 +588,7 @@ export async function composeRelayApp(options) {
588
588
  if (rawInteraction.kind === 'autopilotWaitLease') {
589
589
  const lease = rawInteraction.payload;
590
590
  const rootOwned = origin.kind === 'root' && origin.physicalTurnId === session.activeTurnId;
591
- const accepted = rootOwned &&
591
+ const registration = rootOwned &&
592
592
  (lease.version === 2
593
593
  ? autopilot.registerProactiveWait(sessionId, {
594
594
  id: lease.reportId,
@@ -602,10 +602,12 @@ export async function composeRelayApp(options) {
602
602
  leaseId: lease.leaseId,
603
603
  wakeConditions: lease.wakeConditions,
604
604
  }));
605
- if (!accepted) {
606
- if (rootOwned)
605
+ if (registration !== true) {
606
+ if (rootOwned && registration === false)
607
607
  autopilot.rejectProbe(sessionId);
608
- return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, autopilotWaitLeaseToolResponse(false)) === true);
608
+ return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, autopilotWaitLeaseToolResponse(false, registration === 'wakeAlreadySatisfied'
609
+ ? 'wakeAlreadySatisfied'
610
+ : 'automaticContinuationUnavailable')) === true);
609
611
  }
610
612
  return (runtime?.resolveServerRequest(sessionId, rawInteraction.requestId, autopilotWaitLeaseToolResponse()) === true);
611
613
  }
@@ -3,8 +3,8 @@
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 = 'v9';
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. 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. 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.';
6
+ export const AUTOPILOT_PROMPT_VERSION = 'v10';
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. */
10
10
  export function autopilotExecutorLaunchPrompt(identity) {
@@ -162,6 +162,24 @@ export class AutopilotCoordinator {
162
162
  report.maxWaitMs > AUTOPILOT_WAIT_MAX_MS)
163
163
  return false;
164
164
  const now = this.deps.now();
165
+ if (this.executorWakeAlreadySatisfied(sessionId, state, report.wakeConditions)) {
166
+ // The executor edge is already visible. Fence every pending automatic
167
+ // callback before returning control to the still-active root; otherwise
168
+ // a scheduled executor resume could race same-turn supervision.
169
+ const cancelled = this.cancelScheduledControl(state, now);
170
+ this.persist({
171
+ ...state,
172
+ generation: state.generation + 1,
173
+ ...(cancelled ? { lastControlId: null } : {}),
174
+ nextEvaluationAt: null,
175
+ updatedAt: now,
176
+ }, cancelled);
177
+ this.timers.get(sessionId)?.();
178
+ this.timers.delete(sessionId);
179
+ this.completionTimers.get(sessionId)?.();
180
+ this.completionTimers.delete(sessionId);
181
+ return 'wakeAlreadySatisfied';
182
+ }
165
183
  const resumeAt = new Date(Date.parse(now) + report.maxWaitMs).toISOString();
166
184
  const protocol = state.supervision ?? startSupervisionProtocol(this.progressKey(sessionId));
167
185
  const nextProtocol = registerProtocolWait(protocol, { ...report, resumeAt });
@@ -185,6 +203,19 @@ export class AutopilotCoordinator {
185
203
  this.armWaitDeadline(sessionId, report.leaseId, resumeAt);
186
204
  return true;
187
205
  }
206
+ /** Prevents an edge-triggered executor lease from parking after its edge. */
207
+ executorWakeAlreadySatisfied(sessionId, state, wakeConditions) {
208
+ if (!wakeConditions.includes('executorChanged'))
209
+ return false;
210
+ if (this.completionTimers.has(sessionId))
211
+ return true;
212
+ const retained = this.deps.plan(sessionId);
213
+ const activity = this.deps.activity(sessionId);
214
+ if (!retained || activity?.confidence !== 'fresh')
215
+ return false;
216
+ const executor = this.currentExecutor(sessionId, retained.plan, state.consecutiveNoProgress, state.executor);
217
+ return Boolean(executor && !this.authoritativeExecutorActive(state, retained.plan, activity));
218
+ }
188
219
  /** Fails closed only for the currently active bounded probe. */
189
220
  rejectProbe(sessionId) {
190
221
  const state = this.deps.store.find(sessionId);
@@ -1074,6 +1105,10 @@ export class AutopilotCoordinator {
1074
1105
  this.completionTimers.delete(sessionId);
1075
1106
  this.executorTimers.get(sessionId)?.();
1076
1107
  this.executorTimers.delete(sessionId);
1108
+ this.cancelScheduledExecutorCommands(sessionId);
1109
+ }
1110
+ /** Cancels executor authority while allowing a delivered stale callback to audit itself. */
1111
+ cancelScheduledExecutorCommands(sessionId) {
1077
1112
  const state = this.deps.store.find(sessionId);
1078
1113
  const executor = state?.executor;
1079
1114
  if (state && executor?.commands?.some((command) => command.status === 'scheduled'))
@@ -201,9 +201,10 @@ function measurementFor(heading) {
201
201
  if (value === undefined)
202
202
  continue;
203
203
  if (property.endsWith('_AT')) {
204
- if (!isUtcIsoInstant(value))
204
+ const instant = normalizeUtcIsoInstant(value);
205
+ if (!instant)
205
206
  return null;
206
- measurement[field] = value;
207
+ measurement[field] = instant;
207
208
  continue;
208
209
  }
209
210
  if (!/^\d+$/.test(value))
@@ -215,14 +216,38 @@ function measurementFor(heading) {
215
216
  }
216
217
  return Object.keys(measurement).length === 0 ? undefined : measurement;
217
218
  }
218
- function isUtcIsoInstant(value) {
219
- if (!/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d{3})?Z$/.test(value))
220
- return false;
219
+ function normalizeUtcIsoInstant(value) {
220
+ const match = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.(\d{3}))?(Z|[+-]\d{2}:\d{2})$/.exec(value);
221
+ if (!match)
222
+ return null;
223
+ const [, yearValue, monthValue, dayValue, hourValue, minuteValue, secondValue, , zone] = match;
224
+ const [year, month, day, hour, minute, second] = [
225
+ yearValue,
226
+ monthValue,
227
+ dayValue,
228
+ hourValue,
229
+ minuteValue,
230
+ secondValue,
231
+ ].map(Number);
232
+ const offset = zone === 'Z' ? null : zone.slice(1).split(':').map(Number);
233
+ if (year < 1 ||
234
+ month < 1 ||
235
+ month > 12 ||
236
+ day < 1 ||
237
+ day > new Date(Date.UTC(year, month, 0)).getUTCDate() ||
238
+ hour > 23 ||
239
+ minute > 59 ||
240
+ second > 59 ||
241
+ (offset && (offset[0] > 23 || offset[1] > 59)))
242
+ return null;
221
243
  const instant = new Date(value);
222
244
  if (Number.isNaN(instant.getTime()))
223
- return false;
224
- const canonical = instant.toISOString();
225
- return value === canonical || value === canonical.replace('.000Z', 'Z');
245
+ return null;
246
+ if (zone === 'Z') {
247
+ const canonical = instant.toISOString();
248
+ return value === canonical || value === canonical.replace('.000Z', 'Z') ? value : null;
249
+ }
250
+ return instant.toISOString().replace('.000Z', 'Z');
226
251
  }
227
252
  function descriptionFor(heading) {
228
253
  const fields = heading.level === 1 ? ['Effort', 'Goal', 'Notes'] : ['Why', 'Change', 'Tests', 'Done when'];
@@ -74,7 +74,7 @@ export function parseAutopilotWaitLease(value) {
74
74
  maxWaitMs: value.maxWaitMs,
75
75
  };
76
76
  }
77
- export function autopilotWaitLeaseToolResponse(accepted = true) {
77
+ export function autopilotWaitLeaseToolResponse(accepted = true, reason = 'automaticContinuationUnavailable') {
78
78
  return {
79
79
  success: true,
80
80
  contentItems: [
@@ -82,7 +82,7 @@ export function autopilotWaitLeaseToolResponse(accepted = true) {
82
82
  type: 'inputText',
83
83
  text: accepted
84
84
  ? '{"accepted":true}'
85
- : '{"accepted":false,"reason":"automaticContinuationUnavailable","next":"continueSameTurn"}',
85
+ : JSON.stringify({ accepted: false, reason, next: 'continueSameTurn' }),
86
86
  },
87
87
  ],
88
88
  };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "gestalt-mobile",
3
- "version": "0.35.3",
3
+ "version": "0.35.5",
4
4
  "description": "Mobile-first web relay for durable Codex development sessions",
5
5
  "keywords": [
6
6
  "codex",