gestalt-mobile 0.25.2 → 0.25.3

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,7 +16,7 @@ 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-BkpRh3eb.js"></script>
19
+ <script type="module" crossorigin src="/assets/index-CFw8G97O.js"></script>
20
20
  <link rel="stylesheet" crossorigin href="/assets/index-D3wXcWGp.css">
21
21
  </head>
22
22
  <body>
@@ -49,6 +49,9 @@ function liveAppServer(handles) {
49
49
  request: async (method, params) => {
50
50
  handle.calls.push(method);
51
51
  handle.requests.push({ method, params });
52
+ const overridden = await handle.respond?.(method, params);
53
+ if (overridden !== undefined)
54
+ return overridden;
52
55
  if (method === 'thread/start')
53
56
  return { thread: { id: `thread-${handles.length}` } };
54
57
  if (method === 'turn/start')
@@ -363,7 +366,7 @@ describe('production composition', () => {
363
366
  expect(messages.filter((message) => message.event.type === 'autopilot.updated').at(-1)?.event
364
367
  .payload).toMatchObject({ enabled: true });
365
368
  // This is production composition, not a coordinator fake: the scheduler reaches the
366
- // real runtime adapter and can pass only the fixed policy-owned prompt and opaque ID.
369
+ // real runtime adapter and can pass only policy-owned lifecycle context and an opaque ID.
367
370
  await vi.waitFor(() => expect(handles.at(-1)?.calls.filter((call) => call === 'turn/start')).toHaveLength(1), { timeout: 2_500 });
368
371
  const automaticStart = handles.at(-1)?.requests.find((call) => call.method === 'turn/start');
369
372
  expect(automaticStart?.params).toEqual({
@@ -371,7 +374,7 @@ describe('production composition', () => {
371
374
  input: [
372
375
  {
373
376
  type: 'text',
374
- text: AUTOPILOT_CONTINUATION_PROMPT,
377
+ text: `${AUTOPILOT_CONTINUATION_PROMPT} Launch task_name l1 for canonical L1; retain the canonical label in status and review output.`,
375
378
  text_elements: [],
376
379
  },
377
380
  ],
@@ -556,7 +559,7 @@ describe('production composition', () => {
556
559
  expect(events.count).toBe(1);
557
560
  await fixture.app.close();
558
561
  });
559
- it('production restart unexplained issued requires attention while issued persisted activeTurn records one started audit without replay', async () => {
562
+ it('production restart keeps unexplained issued state supervised while persisted activeTurn records one started audit without replay', async () => {
560
563
  let coordinator;
561
564
  const fixture = await createProductionAutopilotFixture({
562
565
  onAutopilotCoordinator: (value) => {
@@ -577,11 +580,11 @@ describe('production composition', () => {
577
580
  database.close();
578
581
  coordinator.restore(fixture.sessionId);
579
582
  await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
580
- autopilot: { enabled: false, state: 'attentionRequired', reason: 'reconcileFailed' },
583
+ autopilot: { enabled: true, state: 'monitoring', reason: 'reconcileFailed' },
581
584
  }));
582
585
  expect(fixture.handles.flatMap((handle) => handle.calls)).not.toContain('turn/start');
583
- // This coordinator safety stop has no tool request id. Recovery is the
584
- // ordinary durable Autopilot toggle, not the attention resolver route.
586
+ // Re-enabling is idempotent because runtime ambiguity remains under
587
+ // condition-based supervision rather than becoming synthetic attention.
585
588
  expect((await fixture.app.inject({
586
589
  method: 'PUT',
587
590
  url: `/api/sessions/${fixture.sessionId}/autopilot`,
@@ -804,6 +807,37 @@ describe('production composition', () => {
804
807
  it('production child-idle transition wakes an idle supervised autopilot', async () => {
805
808
  const timers = [];
806
809
  const fixture = await createProductionAutopilotFixture({
810
+ autopilotActivity: (sessionId) => {
811
+ const observedAt = new Date().toISOString();
812
+ return {
813
+ sessionId,
814
+ rootThreadId: 'thread-1',
815
+ root: {
816
+ state: 'idle',
817
+ reason: 'turnCompleted',
818
+ observedAt,
819
+ lastActivityAt: observedAt,
820
+ },
821
+ subagents: [
822
+ {
823
+ id: 'child-1',
824
+ threadId: 'child-1',
825
+ taskPath: '/root/l1',
826
+ canonicalTaskName: 'l1',
827
+ canonicalPosition: 'L1',
828
+ continuationGeneration: 1,
829
+ outcome: 'partial',
830
+ ownedProcesses: [],
831
+ state: 'idle',
832
+ reason: 'turnCompleted',
833
+ observedAt,
834
+ lastActivityAt: observedAt,
835
+ },
836
+ ],
837
+ aggregateSubagents: 'idle',
838
+ confidence: 'fresh',
839
+ };
840
+ },
807
841
  autopilotSchedule: (callback) => {
808
842
  const timer = { callback, cancelled: false, fired: false };
809
843
  timers.push(timer);
@@ -818,57 +852,17 @@ describe('production composition', () => {
818
852
  payload: { enabled: false },
819
853
  })).statusCode).toBe(200);
820
854
  timers.length = 0;
821
- const handle = fixture.handles.find((candidate) => candidate.notify);
822
- handle.notify({
823
- method: 'thread/started',
824
- params: { thread: { id: 'thread-1', status: { type: 'active' } } },
825
- });
826
- handle.notify({
827
- method: 'item/started',
828
- params: {
829
- item: {
830
- type: 'collabToolCall',
831
- tool: 'spawn_agent',
832
- status: 'inProgress',
833
- senderThreadId: 'thread-1',
834
- receiverThreadId: 'child-1',
835
- agentStatus: 'working',
836
- },
837
- },
838
- });
839
- handle.notify({
840
- method: 'thread/status/changed',
841
- params: { threadId: 'thread-1', status: { type: 'idle' } },
842
- });
843
855
  expect((await fixture.app.inject({
844
856
  method: 'PUT',
845
857
  url: `/api/sessions/${fixture.sessionId}/autopilot`,
846
858
  payload: { enabled: true },
847
859
  })).statusCode).toBe(200);
848
- expect(timers).toHaveLength(0);
849
- handle.notify({
850
- method: 'item/completed',
851
- params: {
852
- item: {
853
- type: 'collabToolCall',
854
- tool: 'wait',
855
- status: 'completed',
856
- senderThreadId: 'thread-1',
857
- receiverThreadId: 'child-1',
858
- agentStatus: 'idle',
859
- },
860
- },
861
- });
862
860
  expect(timers.filter((timer) => !timer.cancelled && !timer.fired)).toHaveLength(1);
863
- for (let index = 0; index < 2; index += 1) {
864
- const timer = timers.find((candidate) => !candidate.cancelled && !candidate.fired);
865
- expect(timer).toBeDefined();
866
- timer.fired = true;
867
- timer.callback();
868
- }
869
- await vi.waitFor(() => expect(fixture.handles
870
- .flatMap((candidate) => candidate.calls)
871
- .filter((call) => call === 'turn/start')).toHaveLength(1));
861
+ const timer = timers.find((candidate) => !candidate.cancelled && !candidate.fired);
862
+ timer.fired = true;
863
+ timer.callback();
864
+ await vi.waitFor(() => expect(fixture.handles.flatMap((candidate) => candidate.requests.filter((call) => call.method === 'turn/start'))).toHaveLength(1));
865
+ expect(fixture.handles.flatMap((candidate) => candidate.requests.filter((call) => call.method === 'turn/start'))[0]?.params).toMatchObject({ threadId: 'child-1' });
872
866
  await fixture.app.close();
873
867
  });
874
868
  it('production blocked child wakes the idle supervisor for bounded recovery', async () => {
@@ -929,8 +923,8 @@ describe('production composition', () => {
929
923
  expect(quietTimer).toBeDefined();
930
924
  quietTimer.fired = true;
931
925
  quietTimer.callback();
926
+ await vi.waitFor(() => expect(timers.find((timer) => !timer.cancelled && !timer.fired)).toBeDefined());
932
927
  const continuationTimer = timers.find((timer) => !timer.cancelled && !timer.fired);
933
- expect(continuationTimer).toBeDefined();
934
928
  continuationTimer.fired = true;
935
929
  continuationTimer.callback();
936
930
  await vi.waitFor(() => expect(fixture.handles
@@ -938,10 +932,9 @@ describe('production composition', () => {
938
932
  .filter((call) => call === 'turn/start')).toHaveLength(1));
939
933
  await fixture.app.close();
940
934
  });
941
- it('production incompatible exhausted reconcile becomes typed attention with no later reads or timers', async () => {
935
+ it('production incompatible reconcile remains supervised and schedules reinspection', async () => {
942
936
  let reconciliations = 0;
943
937
  const timers = [];
944
- let coordinator;
945
938
  const fixture = await createProductionAutopilotFixture({
946
939
  autopilotActivity: () => null,
947
940
  autopilotReconcile: async () => {
@@ -952,9 +945,6 @@ describe('production composition', () => {
952
945
  timers.push(callback);
953
946
  return () => undefined;
954
947
  },
955
- onAutopilotCoordinator: (value) => {
956
- coordinator = value;
957
- },
958
948
  });
959
949
  expect((await fixture.app.inject({
960
950
  method: 'PUT',
@@ -962,14 +952,14 @@ describe('production composition', () => {
962
952
  payload: { enabled: true },
963
953
  })).statusCode).toBe(200);
964
954
  await vi.waitFor(async () => expect((await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json()).toMatchObject({
965
- autopilot: { state: 'attentionRequired', enabled: false, reason: 'reconcileFailed' },
955
+ autopilot: { state: 'monitoring', enabled: true, reason: 'reconcileFailed' },
966
956
  }));
967
957
  const stableReconciliations = reconciliations;
968
958
  const stableTimers = timers.length;
969
- coordinator.evaluate(fixture.sessionId);
970
- await new Promise((resolve) => setTimeout(resolve, 25));
971
- expect(reconciliations).toBe(stableReconciliations);
972
- expect(timers).toHaveLength(stableTimers);
959
+ expect(stableTimers).toBeGreaterThan(0);
960
+ await timers.at(-1)?.();
961
+ await vi.waitFor(() => expect(reconciliations).toBeGreaterThan(stableReconciliations));
962
+ expect(timers.length).toBeGreaterThanOrEqual(stableTimers);
973
963
  await fixture.app.close();
974
964
  });
975
965
  it('production post-acceptance fault recovers one accepted control without a second turn or audit', async () => {
@@ -49,7 +49,7 @@ import { checkpointPlanMeasurement } from './platform/plans/plan-measurement-com
49
49
  import { PlanMeasurementRefresh } from './platform/plans/plan-measurement-refresh.js';
50
50
  import { SqliteAutopilotStore } from './platform/persistence/sqlite-autopilot-store.js';
51
51
  import { AutopilotCoordinator } from './features/autopilot/application/service.js';
52
- import { AUTOPILOT_CONTINUATION_PROMPT, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
52
+ import { AUTOPILOT_CONTINUATION_PROMPT, AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT, defaultAutopilotPolicy, } from './features/autopilot/application/policy.js';
53
53
  import { createRelyingPartyConfig } from './config.js';
54
54
  import { parseOrgPlanAttention } from '../shared/contracts/org-plan-attention.js';
55
55
  const generatedProtocolVersion = 'codex-cli 0.144.3';
@@ -144,11 +144,18 @@ export async function composeRelayApp(options) {
144
144
  ...(history.activeTurnId ? { turnId: history.activeTurnId } : {}),
145
145
  });
146
146
  const children = await runtime.listDirectChildren(session);
147
+ const childProcesses = new Map();
148
+ await mapWithConcurrency([...children], 4, async (child) => {
149
+ childProcesses.set(child.id, await runtime.inspectChildProcesses(session, child));
150
+ });
147
151
  // The writer read is also asynchronous; it cannot publish after the
148
152
  // durable owner has gone away either.
149
153
  if (!sessions.find(sessionId))
150
154
  return;
151
- activity.childrenReconciled(sessionId, occurredAt, children);
155
+ activity.childrenReconciled(sessionId, occurredAt, children.map((child) => ({
156
+ ...child,
157
+ processes: childProcesses.get(child.id) ?? [],
158
+ })));
152
159
  },
153
160
  });
154
161
  const autopilot = new AutopilotCoordinator({
@@ -165,6 +172,15 @@ export async function composeRelayApp(options) {
165
172
  ? options.autopilotActivity(sessionId)
166
173
  : activity.snapshot(sessionId, new Date().toISOString()),
167
174
  pendingInteraction: (sessionId) => interactions.list(sessionId).length > 0,
175
+ attention: (sessionId) => {
176
+ const interaction = interactions
177
+ .list(sessionId)
178
+ .find((candidate) => candidate.kind === 'orgPlanAttention');
179
+ const attention = interaction ? parseOrgPlanAttention(interaction.payload) : null;
180
+ return attention
181
+ ? { reason: attention.reason, resumeCondition: attention.resumeCondition }
182
+ : null;
183
+ },
168
184
  reconcile: async (sessionId) => {
169
185
  if (options.autopilotReconcile)
170
186
  return options.autopilotReconcile(sessionId);
@@ -180,7 +196,7 @@ export async function composeRelayApp(options) {
180
196
  }),
181
197
  nextControlId: (sessionId, generation) => `autopilot-${generation}-${createHash('sha256').update(`${sessionId}:${randomUUID()}`).digest('hex').slice(0, 16)}`,
182
198
  turnStarter: {
183
- start: async (sessionId, controlId, generation) => {
199
+ start: async (sessionId, controlId, generation, launchIdentity) => {
184
200
  const current = () => {
185
201
  const state = autopilotStore.find(sessionId);
186
202
  return Boolean(state &&
@@ -200,7 +216,10 @@ export async function composeRelayApp(options) {
200
216
  if (!current() || writer.session.activeTurnId || interactions.list(sessionId).length)
201
217
  throw new Error('AUTOPILOT_START_UNAVAILABLE');
202
218
  await options.autopilotBeforeTurnAccepted?.({ sessionId, controlId });
203
- const started = await runtime.startTurn(writer.session, AUTOPILOT_CONTINUATION_PROMPT, controlId, new Date().toISOString());
219
+ const continuationPrompt = launchIdentity
220
+ ? `${AUTOPILOT_CONTINUATION_PROMPT} Launch task_name ${launchIdentity.taskName} for canonical ${launchIdentity.canonicalPosition}; retain the canonical label in status and review output.`
221
+ : AUTOPILOT_CONTINUATION_PROMPT;
222
+ const started = await runtime.startTurn(writer.session, continuationPrompt, controlId, new Date().toISOString());
204
223
  if (!started.activeTurnId)
205
224
  throw new Error('AUTOPILOT_START_UNAVAILABLE');
206
225
  sessions.save(started);
@@ -218,6 +237,46 @@ export async function composeRelayApp(options) {
218
237
  });
219
238
  },
220
239
  },
240
+ executorController: {
241
+ resume: async (sessionId, threadId, generation, trigger) => {
242
+ const session = sessions.find(sessionId);
243
+ if (!session || !runtime || interactions.list(sessionId).length)
244
+ throw new Error('AUTOPILOT_EXECUTOR_UNAVAILABLE');
245
+ const writer = await runtime.ensureWriter(session, new Date().toISOString());
246
+ if (interactions.list(sessionId).length)
247
+ throw new Error('AUTOPILOT_EXECUTOR_UNAVAILABLE');
248
+ const context = trigger.kind === 'processExited'
249
+ ? ` Process result ${trigger.resultArtifact} exited and is ready in this executor history.`
250
+ : trigger.kind === 'processResourceLimit'
251
+ ? ` Process ${trigger.processId} exceeded its explicit resource budget and was terminated; diagnose before retrying.`
252
+ : '';
253
+ const clientId = `autopilot-executor-${generation}-${createHash('sha256')
254
+ .update(`${sessionId}:${threadId}:${generation}:${randomUUID()}`)
255
+ .digest('hex')
256
+ .slice(0, 16)}`;
257
+ const turnId = await runtime.startExecutorTurn(writer.session, threadId, `${AUTOPILOT_EXECUTOR_CONTINUATION_PROMPT}${context}`, clientId);
258
+ activity.observe({
259
+ sessionId,
260
+ occurredAt: new Date().toISOString(),
261
+ kind: 'turnStarted',
262
+ threadId,
263
+ turnId,
264
+ });
265
+ },
266
+ refresh: (sessionId) => activity.refresh(sessionId),
267
+ transferProcess: (sessionId, threadId, processId) => {
268
+ activity.transferProcessOwnership(sessionId, threadId, processId, new Date().toISOString());
269
+ },
270
+ consumeProcess: (sessionId, threadId, processId) => {
271
+ runtime?.consumeChildProcessResult(sessionId, threadId, processId);
272
+ },
273
+ terminateProcess: async (sessionId, threadId, processId) => {
274
+ const session = sessions.find(sessionId);
275
+ return session && runtime
276
+ ? runtime.terminateChildProcess(session, threadId, processId)
277
+ : false;
278
+ },
279
+ },
221
280
  publish: (sessionId, type, payload, occurredAt, outboxId) => {
222
281
  if (!sessions.find(sessionId))
223
282
  return;
@@ -3,6 +3,7 @@
3
3
  * Designed by Denis Roio <jaromil@dyne.org>
4
4
  * SPDX-License-Identifier: AGPL-3.0-or-later
5
5
  */
6
+ import { parseOrgPlanAgentIdentity } from '../../../shared/org-plan-position.js';
6
7
  const states = [
7
8
  'blocked',
8
9
  'awaitingHuman',
@@ -81,6 +82,9 @@ export function projectAgentActivity(current, fact) {
81
82
  root.reason = 'missingCollaborationMetadata';
82
83
  else {
83
84
  const before = children.get(fact.childId);
85
+ const taskPath = fact.childTaskPath ?? before?.taskPath;
86
+ const identity = taskPath ? parseOrgPlanAgentIdentity(taskPath) : null;
87
+ const outcome = childOutcome(fact.childStatus, fact.collaborationAction, before?.outcome);
84
88
  const child = Object.freeze({
85
89
  id: fact.childId,
86
90
  ...(fact.childThreadId
@@ -99,6 +103,20 @@ export function projectAgentActivity(current, fact) {
99
103
  : before?.model
100
104
  ? { model: before.model }
101
105
  : {}),
106
+ ...(taskPath ? { taskPath } : {}),
107
+ ...(identity
108
+ ? {
109
+ canonicalTaskName: identity.canonicalTaskName,
110
+ canonicalPosition: identity.canonicalPosition,
111
+ continuationGeneration: identity.generation,
112
+ }
113
+ : {}),
114
+ ...(outcome ? { outcome } : {}),
115
+ ...(fact.childOwnedProcesses
116
+ ? { ownedProcesses: Object.freeze([...fact.childOwnedProcesses]) }
117
+ : before?.ownedProcesses
118
+ ? { ownedProcesses: before.ownedProcesses }
119
+ : {}),
102
120
  state: childState(fact.childStatus, fact.collaborationAction, before?.state),
103
121
  reason: childReason(fact.childStatus, fact.collaborationAction),
104
122
  observedAt: fact.occurredAt,
@@ -145,6 +163,15 @@ function applyStatus(root, status) {
145
163
  else if (status === 'active' || status === 'working')
146
164
  Object.assign(root, { state: 'working', reason: 'turnActive' });
147
165
  }
166
+ function childOutcome(status, action, previous) {
167
+ if (status === 'error' || status === 'failed' || status === 'systemError' || status === 'errored')
168
+ return 'failed';
169
+ if (status === 'interrupted' || status === 'shutdown' || action === 'close_agent')
170
+ return 'cancelled';
171
+ if (status === 'completed' || status === 'idle')
172
+ return 'partial';
173
+ return previous;
174
+ }
148
175
  function childState(status, action, previous = 'working') {
149
176
  if (status === 'error' || status === 'failed' || status === 'systemError' || status === 'errored')
150
177
  return 'blocked';
@@ -91,6 +91,8 @@ export class AgentActivityRegistry {
91
91
  ...(child.nickname ? { childNickname: child.nickname } : {}),
92
92
  ...(child.role ? { childRole: child.role } : {}),
93
93
  ...(child.model ? { childModel: child.model } : {}),
94
+ ...(child.taskPath ? { childTaskPath: child.taskPath } : {}),
95
+ ...(child.processes ? { childOwnedProcesses: child.processes } : {}),
94
96
  });
95
97
  for (const child of next.subagents)
96
98
  if (!seen.has(child.id))
@@ -112,6 +114,28 @@ export class AgentActivityRegistry {
112
114
  }
113
115
  return next;
114
116
  }
117
+ transferProcessOwnership(sessionId, childThreadId, processId, occurredAt) {
118
+ const current = this.snapshot(sessionId, occurredAt);
119
+ const child = current.subagents.find((candidate) => candidate.id === childThreadId);
120
+ if (!child?.ownedProcesses?.some((process) => process.processId === processId))
121
+ return current;
122
+ const next = withActivityConfidence(projectAgentActivity(current, {
123
+ sessionId,
124
+ occurredAt,
125
+ kind: 'collaboration',
126
+ childId: child.id,
127
+ childThreadId: child.threadId,
128
+ childTaskPath: child.taskPath,
129
+ childOwnedProcesses: child.ownedProcesses.map((process) => process.processId === processId
130
+ ? { ...process, ownership: 'supervisor', state: 'detached-active' }
131
+ : process),
132
+ }), 'fresh');
133
+ if (next !== current) {
134
+ this.#snapshots.set(sessionId, next);
135
+ this.publish(next, occurredAt);
136
+ }
137
+ return next;
138
+ }
115
139
  dispose(sessionId) {
116
140
  this.#disposed.add(sessionId);
117
141
  this.#snapshots.delete(sessionId);
@@ -3,14 +3,20 @@
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 = 'v2';
6
+ export const AUTOPILOT_PROMPT_VERSION = 'v3';
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. For a subagent dedicated to that position, use the collaboration-safe task_name l<a> or l<a>_<b> and refer to it by its canonical L label. Invoke gestalt_org_plan_attention only for a decision-table blocker; otherwise immediately perform the next legal lifecycle action. Do not send a status-only response.';
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, take the next legal L2 action, and report only at the L1 review boundary or through structured attention.';
8
9
  export const defaultAutopilotPolicy = Object.freeze({
9
10
  quiescenceMs: 1_000,
10
11
  staleAfterMs: 30_000,
11
12
  retryLimit: 3,
12
13
  actionLimit: 12,
13
14
  actionWindowMs: 10 * 60_000,
15
+ executorContinuationBaseMs: 1_000,
16
+ executorContinuationMaxMs: 60_000,
17
+ processPollMs: 1_000,
18
+ processMaxElapsedMs: 2 * 60 * 60_000,
19
+ processMaxRssBytes: 12 * 1024 * 1024 * 1024,
14
20
  backoffMs: (attempt) => Math.min(60_000, 1_000 * 2 ** Math.max(0, attempt)),
15
21
  promptVersion: AUTOPILOT_PROMPT_VERSION,
16
22
  });
@@ -39,7 +45,7 @@ export function classifyAgentActivity(activity) {
39
45
  /** A deliberately pure, exhaustive safety gate. Adapters may only enact this result. */
40
46
  export function decideAutopilot(input) {
41
47
  const { state, plan, activity, hasPendingInteraction, now, policy } = input;
42
- if (input.hasActiveAttention || state.state === 'attentionRequired')
48
+ if (input.hasActiveAttention)
43
49
  return {
44
50
  kind: 'requestAttention',
45
51
  reason: state.stopReason === 'noPlanProgress' ||
@@ -55,27 +61,23 @@ export function decideAutopilot(input) {
55
61
  return { kind: 'disable', reason: 'planRequired' };
56
62
  if (executionComplete(plan))
57
63
  return { kind: 'complete' };
64
+ // Quiz, approval, and other held requests are ordinary session work. Only a
65
+ // validated Org attention record may turn an incomplete plan into a human stop.
58
66
  if (hasPendingInteraction)
59
- return { kind: 'requestAttention', reason: 'attentionRequired' };
67
+ return { kind: 'observe' };
60
68
  if (!activity || activity.confidence !== 'fresh')
61
69
  return { kind: 'reconcile' };
62
70
  if (Date.parse(now) - Date.parse(activity.root.lastActivityAt) > policy.staleAfterMs)
63
71
  return { kind: 'reconcile' };
64
72
  const disposition = classifyAgentActivity(activity);
65
73
  if (disposition === 'attention')
66
- return { kind: 'requestAttention', reason: 'attentionRequired' };
74
+ return { kind: 'observe' };
67
75
  if (disposition === 'reconcile')
68
76
  return { kind: 'reconcile' };
69
77
  if (disposition !== 'settled')
70
78
  return { kind: 'observe' };
71
- if ((input.automaticActionCount ?? 0) >= policy.actionLimit)
72
- return { kind: 'requestAttention', reason: 'actionRateExceeded' };
73
- // The outcome is intentionally interpreted only through durable lack of plan
74
- // progress: a failed or unknown automatic turn may retry within the same
75
- // bounded budget, while a completed turn with no fingerprint change does too.
76
- if (state.consecutiveNoProgress >= policy.retryLimit &&
77
- ['completed', 'failed', 'unknown', undefined].includes(input.lastTurnOutcome))
78
- return { kind: 'requestAttention', reason: 'noPlanProgress' };
79
+ // Automatic action counts and partial generations pace continuation, but
80
+ // cannot manufacture a human blocker while durable Org state remains WIP.
79
81
  return {
80
82
  kind: 'scheduleContinuation',
81
83
  at: new Date(Date.parse(now) + policy.backoffMs(state.consecutiveNoProgress)).toISOString(),