gestalt-mobile 0.41.3 → 0.42.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.
- package/README.md +13 -0
- package/dist/server/server/cli.js +47 -0
- package/dist/server/server/composition.contracts.js +18 -2
- package/dist/server/server/composition.js +35 -2
- package/dist/server/server/features/agent-activity/model.js +8 -0
- package/dist/server/server/features/agent-activity/registry.js +4 -0
- package/dist/server/server/features/autopilot/application/policy.js +42 -1
- package/dist/server/server/features/autopilot/application/service.js +46 -7
- package/dist/server/server/features/autopilot/domain/supervised-lifecycle.js +6 -0
- package/dist/server/server/features/control-plane-trace/export-trace.js +90 -0
- package/dist/server/server/platform/codex/activity-facts.js +2 -0
- package/dist/server/shared/contracts/org-plan-checkpoint.js +2 -69
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -343,6 +343,19 @@ durable history, deletes only the exact smoke-created Codex threads through the
|
|
|
343
343
|
app-server afterwards, and always removes its temporary state. It reports
|
|
344
344
|
`SKIP` when the isolated profile is unavailable.
|
|
345
345
|
|
|
346
|
+
## Control-plane traces
|
|
347
|
+
|
|
348
|
+
Export the durable Org Plan, Autopilot, and agent-projection timeline for one relay session without starting the server:
|
|
349
|
+
|
|
350
|
+
```sh
|
|
351
|
+
gestalt-mobile trace <session-id>
|
|
352
|
+
gestalt-mobile trace <session-id> --json
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
Use `--cwd <workspace>` to select another workspace state database, or `--data-dir <directory>` when the server uses an explicit data directory. The export excludes chat messages, prompts, model output, and environment values. New checkpoint handoffs share a stable `traceId` across checkpoint persistence, continuation scheduling, control dispatch, turn outcome, and agent projection events. The human format includes bounded diagnoses for missing transitions; JSON is suitable for issue attachments and automated analysis.
|
|
356
|
+
|
|
357
|
+
Browser receipt is intentionally not inferred from server persistence. A trace that contains `agent.activity.updated` proves the backend projection was journaled, while its diagnostic notes that the browser event cursor is still needed to prove delivery.
|
|
358
|
+
|
|
346
359
|
## Run from source
|
|
347
360
|
|
|
348
361
|
```sh
|
|
@@ -15,8 +15,11 @@ import { CliUsageError, parseConfig } from './config.js';
|
|
|
15
15
|
import { LauncherProfileCatalog } from './platform/catalog/launcher-profile-catalog.js';
|
|
16
16
|
import { FilesystemSkillProfileStore } from './platform/skills/filesystem-skill-profile-store.js';
|
|
17
17
|
import { loadPwaIcon } from './platform/pwa/load-pwa-icon.js';
|
|
18
|
+
import { exportControlPlaneTrace, formatControlPlaneTrace, } from './features/control-plane-trace/export-trace.js';
|
|
19
|
+
import { relayStatePath } from './platform/persistence/state-path.js';
|
|
18
20
|
const runFile = promisify(execFile);
|
|
19
21
|
export const usage = `Usage: gestalt-mobile [options]
|
|
22
|
+
gestalt-mobile trace <session-id> [--json] [--cwd <path>] [--data-dir <path>]
|
|
20
23
|
|
|
21
24
|
Options:
|
|
22
25
|
--cwd <path> Workspace root (default: current directory)
|
|
@@ -125,6 +128,33 @@ export function installShutdownHandlers(app, signalSource = process) {
|
|
|
125
128
|
return shutdown;
|
|
126
129
|
}
|
|
127
130
|
function parseInvocation(args, cwd) {
|
|
131
|
+
if (args[0] === 'trace') {
|
|
132
|
+
const sessionId = args[1];
|
|
133
|
+
if (!sessionId || sessionId.startsWith('--'))
|
|
134
|
+
throw new CliUsageError('trace requires a session ID');
|
|
135
|
+
let json = false;
|
|
136
|
+
let root = cwd;
|
|
137
|
+
let dataDir;
|
|
138
|
+
for (let index = 2; index < args.length; index += 1) {
|
|
139
|
+
const option = args[index];
|
|
140
|
+
if (option === '--json') {
|
|
141
|
+
if (json)
|
|
142
|
+
throw new CliUsageError('Duplicate option: --json');
|
|
143
|
+
json = true;
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
if (option !== '--cwd' && option !== '--data-dir')
|
|
147
|
+
throw new CliUsageError(`Unknown trace option: ${option}`);
|
|
148
|
+
const value = args[++index];
|
|
149
|
+
if (!value || value.startsWith('--'))
|
|
150
|
+
throw new CliUsageError(`Missing value for ${option}`);
|
|
151
|
+
if (option === '--cwd')
|
|
152
|
+
root = resolve(cwd, value);
|
|
153
|
+
else
|
|
154
|
+
dataDir = resolve(cwd, value);
|
|
155
|
+
}
|
|
156
|
+
return { command: 'trace', sessionId, json, root, ...(dataDir ? { dataDir } : {}) };
|
|
157
|
+
}
|
|
128
158
|
if (args.includes('--help')) {
|
|
129
159
|
if (args.length !== 1)
|
|
130
160
|
throw new CliUsageError('--help cannot be combined with other arguments');
|
|
@@ -187,6 +217,23 @@ export async function runCli(dependencies = {}) {
|
|
|
187
217
|
stdout.write(`${await packageVersion(moduleUrl)}\n`);
|
|
188
218
|
return 0;
|
|
189
219
|
}
|
|
220
|
+
if (invocation.command === 'trace') {
|
|
221
|
+
const stateHome = dependencies.environment?.XDG_STATE_HOME ??
|
|
222
|
+
process.env.XDG_STATE_HOME ??
|
|
223
|
+
resolve(dependencies.homeDirectory ?? homedir(), '.local/state');
|
|
224
|
+
const databasePath = invocation.dataDir
|
|
225
|
+
? resolve(invocation.dataDir, 'relay.sqlite')
|
|
226
|
+
: relayStatePath(invocation.root, stateHome);
|
|
227
|
+
try {
|
|
228
|
+
const trace = (dependencies.exportTrace ?? exportControlPlaneTrace)(databasePath, invocation.sessionId);
|
|
229
|
+
stdout.write(invocation.json ? `${JSON.stringify(trace, null, 2)}\n` : formatControlPlaneTrace(trace));
|
|
230
|
+
return 0;
|
|
231
|
+
}
|
|
232
|
+
catch (error) {
|
|
233
|
+
stderr.write(`Unable to export control-plane trace: ${error instanceof Error ? error.message : 'unknown error'}\n`);
|
|
234
|
+
return 1;
|
|
235
|
+
}
|
|
236
|
+
}
|
|
190
237
|
const skillProfiles = new FilesystemSkillProfileStore(dependencies.homeDirectory ?? homedir());
|
|
191
238
|
if (invocation.command === 'list')
|
|
192
239
|
return listSkillProfiles(skillProfiles, stdout, stderr);
|
|
@@ -1045,6 +1045,16 @@ describe('production composition', () => {
|
|
|
1045
1045
|
await expect
|
|
1046
1046
|
.poll(async () => (await fixture.app.inject(`/api/sessions/${fixture.sessionId}`)).json().activeTurnId)
|
|
1047
1047
|
.toBeNull();
|
|
1048
|
+
await expect
|
|
1049
|
+
.poll(() => {
|
|
1050
|
+
const database = new DatabaseSync(join(fixture.dataDir, 'relay.sqlite'));
|
|
1051
|
+
const scheduled = database
|
|
1052
|
+
.prepare("SELECT count(*) AS count FROM session_events WHERE session_id = ? AND type = 'autopilot.continuation-scheduled'")
|
|
1053
|
+
.get(fixture.sessionId);
|
|
1054
|
+
database.close();
|
|
1055
|
+
return scheduled.count;
|
|
1056
|
+
})
|
|
1057
|
+
.toBeGreaterThan(0);
|
|
1048
1058
|
const startedTurns = () => fixture.handles
|
|
1049
1059
|
.flatMap((candidate) => candidate.requests)
|
|
1050
1060
|
.filter((request) => request.method === 'turn/start').length;
|
|
@@ -1611,8 +1621,14 @@ describe('production composition', () => {
|
|
|
1611
1621
|
handle.notify(completion);
|
|
1612
1622
|
handle.notify(completion);
|
|
1613
1623
|
await vi.waitFor(() => expect(timers.filter((timer) => !timer.cancelled && !timer.fired)).toHaveLength(1));
|
|
1614
|
-
|
|
1615
|
-
|
|
1624
|
+
for (let attempts = 0; attempts < 3; attempts += 1) {
|
|
1625
|
+
const pending = timers.find((timer) => !timer.cancelled && !timer.fired);
|
|
1626
|
+
if (!pending)
|
|
1627
|
+
break;
|
|
1628
|
+
pending.fired = true;
|
|
1629
|
+
pending.callback();
|
|
1630
|
+
await Promise.resolve();
|
|
1631
|
+
}
|
|
1616
1632
|
await vi.waitFor(() => expect(fixture.handles
|
|
1617
1633
|
.flatMap((candidate) => candidate.calls)
|
|
1618
1634
|
.filter((call) => call === 'turn/start')).toHaveLength(2), { timeout: 3_500 });
|
|
@@ -137,7 +137,14 @@ export async function composeRelayApp(options) {
|
|
|
137
137
|
};
|
|
138
138
|
options.onAttentionTransitions?.(attentionTransitions);
|
|
139
139
|
const activity = new AgentActivityRegistry((snapshot, occurredAt) => {
|
|
140
|
-
events.publish(journal.append(snapshot.sessionId, 'agent.activity.updated',
|
|
140
|
+
events.publish(journal.append(snapshot.sessionId, 'agent.activity.updated', {
|
|
141
|
+
...toAgentActivityDto(snapshot),
|
|
142
|
+
...(autopilotStore.find(snapshot.sessionId)?.checkpoints?.activeHandoffId
|
|
143
|
+
? {
|
|
144
|
+
traceId: autopilotStore.find(snapshot.sessionId).checkpoints.activeHandoffId,
|
|
145
|
+
}
|
|
146
|
+
: {}),
|
|
147
|
+
}, occurredAt));
|
|
141
148
|
publishSessionStatus(snapshot.sessionId, occurredAt);
|
|
142
149
|
notifyAutopilotActivity(snapshot.sessionId);
|
|
143
150
|
}, {
|
|
@@ -496,7 +503,13 @@ export async function composeRelayApp(options) {
|
|
|
496
503
|
if (!interactions.resolve(sessionId, requestId, occurredAt, 'failed'))
|
|
497
504
|
return false;
|
|
498
505
|
autopilot.checkpointHandoffFailed(sessionId, turnId);
|
|
499
|
-
events.publish(journal.append(sessionId, 'org-plan.checkpoint-handoff-failed', {
|
|
506
|
+
events.publish(journal.append(sessionId, 'org-plan.checkpoint-handoff-failed', {
|
|
507
|
+
requestId,
|
|
508
|
+
reason: 'checkpointHandoffFailed',
|
|
509
|
+
...(autopilotStore.find(sessionId)?.checkpoints?.activeHandoffId
|
|
510
|
+
? { traceId: autopilotStore.find(sessionId).checkpoints.activeHandoffId }
|
|
511
|
+
: {}),
|
|
512
|
+
}, occurredAt));
|
|
500
513
|
publishAttentionSettlement(sessionId, requestId, occurredAt, 'failed');
|
|
501
514
|
// Neither this timeout nor a failed transport may schedule continuation.
|
|
502
515
|
// If a successful response is already blocked in the writable stream, drop
|
|
@@ -696,9 +709,16 @@ export async function composeRelayApp(options) {
|
|
|
696
709
|
const rootCommandId = resolvedOrigin.kind === 'root' ? completedCommandId(notification) : null;
|
|
697
710
|
if (rootCommandId)
|
|
698
711
|
autopilot.rootProcessCompleted(sessionId, rootCommandId);
|
|
712
|
+
const refreshChildTopology = notification.method === 'item/completed' &&
|
|
713
|
+
activityFacts.some((fact) => fact.kind === 'collaboration' &&
|
|
714
|
+
(fact.collaborationAction === 'spawn_agent' ||
|
|
715
|
+
fact.collaborationAction === 'resume_agent' ||
|
|
716
|
+
fact.collaborationAction === 'close_agent'));
|
|
699
717
|
if (!normalized) {
|
|
700
718
|
for (const activityFact of activityFacts)
|
|
701
719
|
activity.observe(activityFact);
|
|
720
|
+
if (refreshChildTopology)
|
|
721
|
+
void activity.refresh(sessionId);
|
|
702
722
|
return;
|
|
703
723
|
}
|
|
704
724
|
let completedSession;
|
|
@@ -717,6 +737,19 @@ export async function composeRelayApp(options) {
|
|
|
717
737
|
// activeTurnId while the completion fact makes the root appear idle.
|
|
718
738
|
for (const activityFact of activityFacts)
|
|
719
739
|
activity.observe(activityFact);
|
|
740
|
+
// Re-evaluate once both halves of the safe continuation boundary are
|
|
741
|
+
// observable. turnCompleted() releases the durable checkpoint before
|
|
742
|
+
// these facts project the idle root; its first evaluation can therefore
|
|
743
|
+
// still see the pre-completion activity snapshot. This second evaluation
|
|
744
|
+
// is idempotently fenced by the durable control row.
|
|
745
|
+
if (completedSession)
|
|
746
|
+
autopilot.evaluate(sessionId);
|
|
747
|
+
// Lifecycle notifications are fast transition evidence, while
|
|
748
|
+
// thread/list is the bounded topology authority. Reconcile after the
|
|
749
|
+
// mutation so canonical identity and every retained child reach both
|
|
750
|
+
// the GUI and Autopilot even when the notification is sparse.
|
|
751
|
+
if (refreshChildTopology)
|
|
752
|
+
void activity.refresh(sessionId);
|
|
720
753
|
events.publish(journal.append(sessionId, normalized.type, normalized.payload, normalized.occurredAt));
|
|
721
754
|
if (completedSession)
|
|
722
755
|
events.publish(journal.append(sessionId, 'session.updated', completedSession, occurredAt));
|
|
@@ -196,6 +196,14 @@ function childOutcome(status, action, previous) {
|
|
|
196
196
|
return 'cancelled';
|
|
197
197
|
if (status === 'completed' || status === 'idle')
|
|
198
198
|
return 'partial';
|
|
199
|
+
// A follow-up reopens the same physical child. Do not keep presenting its
|
|
200
|
+
// previous boundary completion while its new turn is running.
|
|
201
|
+
if (action === 'resume_agent' ||
|
|
202
|
+
status === 'working' ||
|
|
203
|
+
status === 'active' ||
|
|
204
|
+
status === 'running' ||
|
|
205
|
+
status === 'pendingInit')
|
|
206
|
+
return undefined;
|
|
199
207
|
return previous;
|
|
200
208
|
}
|
|
201
209
|
function childState(status, action, previous = 'working') {
|
|
@@ -102,6 +102,10 @@ export class AgentActivityRegistry {
|
|
|
102
102
|
kind: 'collaboration',
|
|
103
103
|
childId: child.id,
|
|
104
104
|
childStatus: 'notLoaded',
|
|
105
|
+
// The bounded roster/process read is authoritative as a unit. If a
|
|
106
|
+
// child disappeared, its previous runtime-local process projection
|
|
107
|
+
// cannot remain eligible as live work after this reconciliation.
|
|
108
|
+
childOwnedProcesses: [],
|
|
105
109
|
});
|
|
106
110
|
// A malformed/unknown `thread/list` row is not proof that a child is
|
|
107
111
|
// healthy. Preserve it as disconnected but keep the aggregate qualified
|
|
@@ -49,6 +49,47 @@ export function classifyAgentActivity(activity) {
|
|
|
49
49
|
const subagentsSettled = activity.aggregateSubagents === 'idle' || activity.aggregateSubagents === 'blocked';
|
|
50
50
|
return rootSettled && subagentsSettled ? 'settled' : 'observe';
|
|
51
51
|
}
|
|
52
|
+
/**
|
|
53
|
+
* Classifies actor state against the plan position that can legally run next.
|
|
54
|
+
* Completed canonical executors remain visible evidence, but their terminal
|
|
55
|
+
* topology cannot indefinitely turn a fresh read into a reconciliation loop.
|
|
56
|
+
*/
|
|
57
|
+
function classifySupervisedActivity(activity, plan) {
|
|
58
|
+
const selected = plan.steps.findIndex((step) => step.id === plan.currentStepId || step.state === 'WIP');
|
|
59
|
+
const index = selected >= 0 ? selected : plan.steps.findIndex((step) => step.state !== 'DONE');
|
|
60
|
+
if (index < 0)
|
|
61
|
+
return classifyAgentActivity(activity);
|
|
62
|
+
const position = index + 1;
|
|
63
|
+
const retained = activity.subagents.filter((child) => {
|
|
64
|
+
const match = /^L([1-9]\d*)$/.exec(child.canonicalPosition ?? '');
|
|
65
|
+
if (!match)
|
|
66
|
+
return true;
|
|
67
|
+
const historical = Number(match[1]) < position;
|
|
68
|
+
const supervisedProcess = child.ownedProcesses?.some((process) => process.ownership === 'supervisor' &&
|
|
69
|
+
(process.state === 'running' || process.state === 'detached-active'));
|
|
70
|
+
return !historical || supervisedProcess;
|
|
71
|
+
});
|
|
72
|
+
// An aggregate-only observation cannot prove which child is historical.
|
|
73
|
+
if (retained.length === activity.subagents.length)
|
|
74
|
+
return classifyAgentActivity(activity);
|
|
75
|
+
if (activity.root.state === 'awaitingHuman' ||
|
|
76
|
+
retained.some((child) => child.state === 'awaitingHuman'))
|
|
77
|
+
return 'attention';
|
|
78
|
+
if (activity.root.state === 'working' ||
|
|
79
|
+
retained.some((child) => child.state === 'working' ||
|
|
80
|
+
child.state === 'awaitingAgent' ||
|
|
81
|
+
child.ownedProcesses?.some((process) => (child.state !== 'disconnected' || process.ownership === 'supervisor') &&
|
|
82
|
+
(process.state === 'running' || process.state === 'detached-active'))))
|
|
83
|
+
return 'active';
|
|
84
|
+
if (activity.root.state === 'disconnected' ||
|
|
85
|
+
retained.some((child) => child.state === 'disconnected'))
|
|
86
|
+
return 'reconcile';
|
|
87
|
+
const rootSettled = activity.root.state === 'idle' ||
|
|
88
|
+
activity.root.state === 'blocked' ||
|
|
89
|
+
activity.root.state === 'awaitingAgent';
|
|
90
|
+
const childrenSettled = retained.every((child) => child.state === 'idle' || child.state === 'blocked');
|
|
91
|
+
return rootSettled && childrenSettled ? 'settled' : 'observe';
|
|
92
|
+
}
|
|
52
93
|
/** A deliberately pure, exhaustive safety gate. Adapters may only enact this result. */
|
|
53
94
|
export function decideAutopilot(input) {
|
|
54
95
|
const { state, plan, activity, hasPendingInteraction, now, policy } = input;
|
|
@@ -93,7 +134,7 @@ export function decideAutopilot(input) {
|
|
|
93
134
|
return { kind: 'reconcile' };
|
|
94
135
|
if (Date.parse(now) - Date.parse(activity.root.observedAt) > policy.staleAfterMs)
|
|
95
136
|
return { kind: 'reconcile' };
|
|
96
|
-
const disposition =
|
|
137
|
+
const disposition = classifySupervisedActivity(activity, plan);
|
|
97
138
|
if (disposition === 'attention')
|
|
98
139
|
return { kind: 'observe' };
|
|
99
140
|
if (disposition === 'reconcile')
|
|
@@ -525,7 +525,12 @@ export class AutopilotCoordinator {
|
|
|
525
525
|
{
|
|
526
526
|
sessionId,
|
|
527
527
|
type: 'autopilot.continuation-scheduled',
|
|
528
|
-
payload: {
|
|
528
|
+
payload: {
|
|
529
|
+
controlId: scheduledControlId,
|
|
530
|
+
...(prior.checkpoints?.activeHandoffId
|
|
531
|
+
? { traceId: prior.checkpoints.activeHandoffId }
|
|
532
|
+
: {}),
|
|
533
|
+
},
|
|
529
534
|
occurredAt: now,
|
|
530
535
|
},
|
|
531
536
|
]);
|
|
@@ -639,7 +644,12 @@ export class AutopilotCoordinator {
|
|
|
639
644
|
: pendingKind === 'terminalReviewAccepted'
|
|
640
645
|
? 'org-plan.terminal-review-reported'
|
|
641
646
|
: 'org-plan.milestone-reported',
|
|
642
|
-
payload: {
|
|
647
|
+
payload: {
|
|
648
|
+
turnId: state.checkpoints.pendingTurnId,
|
|
649
|
+
...(state.checkpoints.activeHandoffId
|
|
650
|
+
? { traceId: state.checkpoints.activeHandoffId }
|
|
651
|
+
: {}),
|
|
652
|
+
},
|
|
643
653
|
occurredAt,
|
|
644
654
|
},
|
|
645
655
|
]);
|
|
@@ -700,6 +710,7 @@ export class AutopilotCoordinator {
|
|
|
700
710
|
...(previous?.completionEpochs ?? []).filter((entry) => entry.target !== target),
|
|
701
711
|
{ target, epoch, reopened: false, completed: true },
|
|
702
712
|
];
|
|
713
|
+
const handoffId = `handoff-${key.slice(0, 24)}`;
|
|
703
714
|
const checkpoints = {
|
|
704
715
|
protocolVersion: 1,
|
|
705
716
|
planIdentity: retained.identity,
|
|
@@ -710,6 +721,7 @@ export class AutopilotCoordinator {
|
|
|
710
721
|
: reportedL1Ids,
|
|
711
722
|
acceptedKeys: boundedUnique([...acceptedKeys, key], 768),
|
|
712
723
|
pendingTurnId: turnId,
|
|
724
|
+
activeHandoffId: handoffId,
|
|
713
725
|
pendingKind: checkpoint.kind,
|
|
714
726
|
checkpointHandoffFailed: false,
|
|
715
727
|
terminalReviewAccepted: checkpoint.kind === 'terminalReviewAccepted' || previous?.terminalReviewAccepted === true,
|
|
@@ -746,10 +758,16 @@ export class AutopilotCoordinator {
|
|
|
746
758
|
l2Id: checkpoint.l2Id,
|
|
747
759
|
position: checkpoint.position,
|
|
748
760
|
turnId,
|
|
761
|
+
traceId: handoffId,
|
|
749
762
|
}
|
|
750
763
|
: checkpoint.kind === 'l1Accepted'
|
|
751
|
-
? {
|
|
752
|
-
|
|
764
|
+
? {
|
|
765
|
+
l1Id: checkpoint.l1Id,
|
|
766
|
+
position: checkpoint.position,
|
|
767
|
+
turnId,
|
|
768
|
+
traceId: handoffId,
|
|
769
|
+
}
|
|
770
|
+
: { turnId, traceId: handoffId },
|
|
753
771
|
occurredAt,
|
|
754
772
|
},
|
|
755
773
|
]);
|
|
@@ -1030,7 +1048,17 @@ export class AutopilotCoordinator {
|
|
|
1030
1048
|
const issuedControl = { ...priorControl, status: 'issued', updatedAt: now };
|
|
1031
1049
|
const events = [
|
|
1032
1050
|
...this.snapshotEvents(next, issuedControl),
|
|
1033
|
-
{
|
|
1051
|
+
{
|
|
1052
|
+
sessionId,
|
|
1053
|
+
type: 'autopilot.control-issued',
|
|
1054
|
+
payload: {
|
|
1055
|
+
controlId,
|
|
1056
|
+
...(prior.checkpoints?.activeHandoffId
|
|
1057
|
+
? { traceId: prior.checkpoints.activeHandoffId }
|
|
1058
|
+
: {}),
|
|
1059
|
+
},
|
|
1060
|
+
occurredAt: now,
|
|
1061
|
+
},
|
|
1034
1062
|
];
|
|
1035
1063
|
const control = this.deps.store.claimControlIssued
|
|
1036
1064
|
? this.deps.store.claimControlIssued(sessionId, controlId, now, next, events)
|
|
@@ -2140,8 +2168,19 @@ export class AutopilotCoordinator {
|
|
|
2140
2168
|
sessionId,
|
|
2141
2169
|
type: eventType,
|
|
2142
2170
|
payload: eventType === 'autopilot.turn-failed'
|
|
2143
|
-
? {
|
|
2144
|
-
|
|
2171
|
+
? {
|
|
2172
|
+
controlId,
|
|
2173
|
+
code: failureCode,
|
|
2174
|
+
...(this.deps.store.find(sessionId)?.checkpoints?.activeHandoffId
|
|
2175
|
+
? { traceId: this.deps.store.find(sessionId).checkpoints.activeHandoffId }
|
|
2176
|
+
: {}),
|
|
2177
|
+
}
|
|
2178
|
+
: {
|
|
2179
|
+
controlId,
|
|
2180
|
+
...(this.deps.store.find(sessionId)?.checkpoints?.activeHandoffId
|
|
2181
|
+
? { traceId: this.deps.store.find(sessionId).checkpoints.activeHandoffId }
|
|
2182
|
+
: {}),
|
|
2183
|
+
},
|
|
2145
2184
|
occurredAt,
|
|
2146
2185
|
},
|
|
2147
2186
|
]
|
|
@@ -390,6 +390,11 @@ function parseCheckpoints(value) {
|
|
|
390
390
|
const pendingTurnId = value.pendingTurnId === null ? null : boundedText(value.pendingTurnId);
|
|
391
391
|
if (pendingTurnId === undefined)
|
|
392
392
|
return undefined;
|
|
393
|
+
const activeHandoffId = value.activeHandoffId === undefined || value.activeHandoffId === null
|
|
394
|
+
? null
|
|
395
|
+
: boundedText(value.activeHandoffId);
|
|
396
|
+
if (activeHandoffId === undefined)
|
|
397
|
+
return undefined;
|
|
393
398
|
if (value.checkpointHandoffFailed !== undefined &&
|
|
394
399
|
typeof value.checkpointHandoffFailed !== 'boolean')
|
|
395
400
|
return undefined;
|
|
@@ -437,6 +442,7 @@ function parseCheckpoints(value) {
|
|
|
437
442
|
reportedL1Ids,
|
|
438
443
|
acceptedKeys,
|
|
439
444
|
pendingTurnId,
|
|
445
|
+
...(activeHandoffId ? { activeHandoffId } : {}),
|
|
440
446
|
pendingKind: pendingKind,
|
|
441
447
|
...(value.checkpointHandoffFailed === undefined
|
|
442
448
|
? {}
|
|
@@ -0,0 +1,90 @@
|
|
|
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
|
+
import { DatabaseSync } from 'node:sqlite';
|
|
7
|
+
const relevant = /^(?:org-plan\.|autopilot\.|agent\.activity\.|session\.status\.)/;
|
|
8
|
+
function objectPayload(value) {
|
|
9
|
+
try {
|
|
10
|
+
const parsed = JSON.parse(value);
|
|
11
|
+
return parsed && typeof parsed === 'object' && !Array.isArray(parsed)
|
|
12
|
+
? parsed
|
|
13
|
+
: {};
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
return {};
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
function correlation(payload) {
|
|
20
|
+
for (const key of ['traceId', 'handoffId', 'controlId', 'turnId', 'requestId'])
|
|
21
|
+
if (typeof payload[key] === 'string' && payload[key])
|
|
22
|
+
return String(payload[key]);
|
|
23
|
+
return null;
|
|
24
|
+
}
|
|
25
|
+
function diagnose(events) {
|
|
26
|
+
const diagnoses = [];
|
|
27
|
+
if (!events.length)
|
|
28
|
+
return ['no control-plane events found for this session'];
|
|
29
|
+
const checkpointIndex = events.findLastIndex((event) => event.type.endsWith('-checkpointed'));
|
|
30
|
+
const currentHandoff = checkpointIndex < 0 ? events : events.slice(checkpointIndex);
|
|
31
|
+
const types = new Set(currentHandoff.map((event) => event.type));
|
|
32
|
+
if ([...types].some((type) => type.endsWith('-checkpointed')) &&
|
|
33
|
+
!types.has('autopilot.continuation-scheduled'))
|
|
34
|
+
diagnoses.push('checkpoint persisted, but no continuation was scheduled');
|
|
35
|
+
if (types.has('autopilot.continuation-scheduled') &&
|
|
36
|
+
!types.has('autopilot.control-issued') &&
|
|
37
|
+
!types.has('autopilot.executor-resumed'))
|
|
38
|
+
diagnoses.push('continuation scheduled, but no control or executor dispatch was observed');
|
|
39
|
+
if (types.has('autopilot.control-issued') &&
|
|
40
|
+
!types.has('autopilot.turn-started') &&
|
|
41
|
+
!types.has('autopilot.turn-failed'))
|
|
42
|
+
diagnoses.push('control issued, but no turn acceptance or failure was observed');
|
|
43
|
+
if (types.has('org-plan.checkpoint-handoff-failed'))
|
|
44
|
+
diagnoses.push('checkpoint response handoff failed; inspect the matching trace ID');
|
|
45
|
+
if (currentHandoff.some((event) => event.type === 'agent.activity.updated'))
|
|
46
|
+
diagnoses.push('backend agent projection was recorded; browser delivery still requires the client event cursor');
|
|
47
|
+
return diagnoses;
|
|
48
|
+
}
|
|
49
|
+
export function exportControlPlaneTrace(databasePath, sessionId, generatedAt = new Date().toISOString()) {
|
|
50
|
+
const database = new DatabaseSync(databasePath, { readOnly: true });
|
|
51
|
+
try {
|
|
52
|
+
const rows = database
|
|
53
|
+
.prepare('SELECT sequence,occurred_at,type,payload_json FROM session_events WHERE session_id = ? ORDER BY sequence')
|
|
54
|
+
.all(sessionId);
|
|
55
|
+
const events = rows
|
|
56
|
+
.filter((row) => relevant.test(row.type))
|
|
57
|
+
.map((row) => {
|
|
58
|
+
const payload = objectPayload(row.payload_json);
|
|
59
|
+
return {
|
|
60
|
+
sequence: row.sequence,
|
|
61
|
+
occurredAt: row.occurred_at,
|
|
62
|
+
type: row.type,
|
|
63
|
+
payload,
|
|
64
|
+
traceId: correlation(payload),
|
|
65
|
+
};
|
|
66
|
+
});
|
|
67
|
+
return { schemaVersion: 1, sessionId, generatedAt, events, diagnoses: diagnose(events) };
|
|
68
|
+
}
|
|
69
|
+
finally {
|
|
70
|
+
database.close();
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
export function formatControlPlaneTrace(trace) {
|
|
74
|
+
const lines = [
|
|
75
|
+
`Control-plane trace for ${trace.sessionId}`,
|
|
76
|
+
`Generated: ${trace.generatedAt}`,
|
|
77
|
+
'',
|
|
78
|
+
];
|
|
79
|
+
for (const event of trace.events) {
|
|
80
|
+
const correlationLabel = event.traceId ? ` trace=${event.traceId}` : '';
|
|
81
|
+
lines.push(`${String(event.sequence).padStart(6)} ${event.occurredAt} ${event.type}${correlationLabel}`);
|
|
82
|
+
}
|
|
83
|
+
lines.push('', 'Diagnosis:');
|
|
84
|
+
if (!trace.diagnoses.length)
|
|
85
|
+
lines.push(' No incomplete control-plane transition detected.');
|
|
86
|
+
else
|
|
87
|
+
for (const diagnosis of trace.diagnoses)
|
|
88
|
+
lines.push(` - ${diagnosis}`);
|
|
89
|
+
return `${lines.join('\n')}\n`;
|
|
90
|
+
}
|
|
@@ -147,10 +147,12 @@ function collaborationAction(tool) {
|
|
|
147
147
|
spawnAgent: 'spawn_agent',
|
|
148
148
|
sendInput: 'send_input',
|
|
149
149
|
resumeAgent: 'resume_agent',
|
|
150
|
+
followupTask: 'resume_agent',
|
|
150
151
|
closeAgent: 'close_agent',
|
|
151
152
|
spawn_agent: 'spawn_agent',
|
|
152
153
|
send_input: 'send_input',
|
|
153
154
|
resume_agent: 'resume_agent',
|
|
155
|
+
followup_task: 'resume_agent',
|
|
154
156
|
close_agent: 'close_agent',
|
|
155
157
|
wait: 'wait',
|
|
156
158
|
}[tool];
|
|
@@ -10,81 +10,14 @@ const record = (value) => typeof value === 'object' && value !== null && !Array.
|
|
|
10
10
|
export const gestaltOrgPlanCheckpointDynamicTool = {
|
|
11
11
|
type: 'function',
|
|
12
12
|
name: GESTALT_ORG_PLAN_CHECKPOINT_TOOL_NAME,
|
|
13
|
-
description: 'Record a validated supervised Org Plan report boundary.
|
|
13
|
+
description: 'Record a validated supervised Org Plan report boundary. Send only the boundary kind; Mobile derives and validates all details from authoritative control state.',
|
|
14
14
|
inputSchema: {
|
|
15
15
|
type: 'object',
|
|
16
16
|
additionalProperties: false,
|
|
17
|
-
required: ['
|
|
17
|
+
required: ['kind'],
|
|
18
18
|
properties: {
|
|
19
|
-
version: { const: 1 },
|
|
20
19
|
kind: { type: 'string', enum: ['l2Completed', 'l1Accepted', 'terminalReviewAccepted'] },
|
|
21
|
-
planIdentity: { type: 'string', minLength: 1, maxLength: 128 },
|
|
22
|
-
l1Id: { type: 'string', minLength: 1, maxLength: 128 },
|
|
23
|
-
l2Id: { type: 'string', minLength: 1, maxLength: 128 },
|
|
24
|
-
position: { type: 'string', pattern: '^L[1-9][0-9]*(\\.[1-9][0-9]*)?$', maxLength: 32 },
|
|
25
|
-
status: { const: 'DONE' },
|
|
26
|
-
verdict: { const: 'ACCEPT' },
|
|
27
|
-
commit: {
|
|
28
|
-
oneOf: [
|
|
29
|
-
{
|
|
30
|
-
type: 'object',
|
|
31
|
-
additionalProperties: false,
|
|
32
|
-
required: ['kind', 'subject', 'shortHash'],
|
|
33
|
-
properties: {
|
|
34
|
-
kind: { const: 'created' },
|
|
35
|
-
subject: { type: 'string', minLength: 1, maxLength: 160 },
|
|
36
|
-
shortHash: { type: 'string', pattern: '^[0-9a-f]{7,16}$' },
|
|
37
|
-
},
|
|
38
|
-
},
|
|
39
|
-
{
|
|
40
|
-
type: 'object',
|
|
41
|
-
additionalProperties: false,
|
|
42
|
-
required: ['kind'],
|
|
43
|
-
properties: { kind: { const: 'notRequired' } },
|
|
44
|
-
},
|
|
45
|
-
],
|
|
46
|
-
},
|
|
47
|
-
findings: { type: 'string', minLength: 1, maxLength: 600 },
|
|
48
|
-
changes: { type: 'string', minLength: 1, maxLength: 600 },
|
|
49
|
-
files: { type: 'string', minLength: 1, maxLength: 600 },
|
|
50
|
-
tests: { type: 'string', minLength: 1, maxLength: 600 },
|
|
51
20
|
},
|
|
52
|
-
oneOf: [
|
|
53
|
-
{
|
|
54
|
-
properties: { kind: { const: 'l2Completed' } },
|
|
55
|
-
required: ['l1Id', 'l2Id', 'position', 'status', 'changes', 'files', 'tests'],
|
|
56
|
-
not: {
|
|
57
|
-
anyOf: [{ required: ['verdict'] }, { required: ['commit'] }, { required: ['findings'] }],
|
|
58
|
-
},
|
|
59
|
-
},
|
|
60
|
-
{
|
|
61
|
-
properties: { kind: { const: 'l1Accepted' } },
|
|
62
|
-
required: ['l1Id', 'position', 'verdict', 'commit'],
|
|
63
|
-
not: {
|
|
64
|
-
anyOf: [
|
|
65
|
-
{ required: ['l2Id'] },
|
|
66
|
-
{ required: ['status'] },
|
|
67
|
-
{ required: ['changes'] },
|
|
68
|
-
{ required: ['files'] },
|
|
69
|
-
],
|
|
70
|
-
},
|
|
71
|
-
},
|
|
72
|
-
{
|
|
73
|
-
properties: { kind: { const: 'terminalReviewAccepted' } },
|
|
74
|
-
required: ['verdict'],
|
|
75
|
-
not: {
|
|
76
|
-
anyOf: [
|
|
77
|
-
{ required: ['l1Id'] },
|
|
78
|
-
{ required: ['l2Id'] },
|
|
79
|
-
{ required: ['position'] },
|
|
80
|
-
{ required: ['status'] },
|
|
81
|
-
{ required: ['commit'] },
|
|
82
|
-
{ required: ['changes'] },
|
|
83
|
-
{ required: ['files'] },
|
|
84
|
-
],
|
|
85
|
-
},
|
|
86
|
-
},
|
|
87
|
-
],
|
|
88
21
|
},
|
|
89
22
|
};
|
|
90
23
|
export function parseOrgPlanCheckpoint(value) {
|