ultra-builder-pro-cli 0.19.1 → 0.21.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/.ultra-template/docs/research/README.md +26 -5
- package/.ultra-template/specs/discovery.md +1 -1
- package/.ultra-template/tasks/tasks.json +1 -1
- package/CHANGELOG.md +90 -1
- package/README.md +62 -29
- package/adapters/_shared/codex-assets.cjs +69 -11
- package/adapters/_shared/interaction-contract.cjs +60 -5
- package/adapters/_shared/runtime-assets.cjs +41 -1
- package/adapters/claude.js +7 -5
- package/adapters/codex.js +1 -1
- package/adapters/kimi.js +2 -8
- package/adapters/opencode.js +30 -28
- package/bin/orchestrator.js +64 -24
- package/commands/ultra-research.md +1 -1
- package/docs/AGENT-CONTEXT.md +79 -45
- package/docs/ARCHITECTURE.md +75 -38
- package/docs/ARTIFACT-AUTHORITY.md +234 -0
- package/docs/DECISIONS.md +20 -10
- package/docs/LEGACY-CLI-CROSSWALK.md +15 -11
- package/docs/PLUGIN-ISOLATION-CONTRACT.md +4 -3
- package/docs/ROADMAP.md +21 -6
- package/docs/RUNTIME-COMPAT-MATRIX.md +30 -13
- package/docs/STATE-DB-ACCESS-POLICY.md +11 -9
- package/docs/WORKFLOW-LIFECYCLE.md +105 -54
- package/hooks/README.md +6 -5
- package/hooks/active_task_context.py +97 -12
- package/hooks/context_spine.py +16 -4
- package/hooks/health_check.py +5 -4
- package/hooks/pre_stop_check.py +5 -2
- package/hooks/runtime_paths.py +396 -0
- package/hooks/subagent_tracker.py +5 -2
- package/hooks/workflow_checkpoint.py +16 -3
- package/hooks/workflow_context.py +11 -3
- package/hooks/workflow_resume.py +7 -4
- package/mcp-server/breadcrumb.cjs +20 -2
- package/mcp-server/lib/archive-journal.cjs +1026 -111
- package/mcp-server/lib/archive-mutation-worker.py +365 -0
- package/mcp-server/lib/artifact-registry.cjs +1126 -0
- package/mcp-server/lib/baseline-workflow.cjs +9 -4
- package/mcp-server/lib/change-packet.cjs +818 -0
- package/mcp-server/lib/change-workflow.cjs +932 -176
- package/mcp-server/lib/context-paths.cjs +111 -0
- package/mcp-server/lib/context-spine.cjs +511 -45
- package/mcp-server/lib/decision-dialogue.cjs +427 -12
- package/mcp-server/lib/delivery-transaction.cjs +471 -0
- package/mcp-server/lib/doctor-backup-worker.cjs +328 -0
- package/mcp-server/lib/doctor.cjs +375 -12
- package/mcp-server/lib/git-bootstrap.cjs +459 -47
- package/mcp-server/lib/init-project.cjs +190 -95
- package/mcp-server/lib/plan-publication-worker.cjs +830 -0
- package/mcp-server/lib/plan-store.cjs +700 -5
- package/mcp-server/lib/project-breadcrumb.cjs +23 -4
- package/mcp-server/lib/projector.cjs +174 -40
- package/mcp-server/lib/runtime-paths.cjs +1687 -0
- package/mcp-server/lib/safe-project-file.cjs +385 -0
- package/mcp-server/lib/spec-learning.cjs +28 -12
- package/mcp-server/lib/state-db.cjs +397 -13
- package/mcp-server/lib/state-ops.cjs +248 -49
- package/mcp-server/lib/telemetry.cjs +12 -3
- package/mcp-server/lib/workflow-state.cjs +614 -62
- package/mcp-server/server.cjs +282 -33
- package/orchestrator/daemon.cjs +2 -10
- package/orchestrator/parallel-orchestrator.cjs +314 -50
- package/orchestrator/planner/plan-builder.cjs +1 -1
- package/orchestrator/recovery.cjs +125 -7
- package/orchestrator/session-close-journal-worker.cjs +5 -0
- package/orchestrator/session-close-journal.cjs +1014 -0
- package/orchestrator/session-runner.cjs +679 -69
- package/orchestrator/worktree-manager.cjs +186 -35
- package/package.json +2 -3
- package/skills/cc-collab/SKILL.md +4 -1
- package/skills/codex-collab/SKILL.md +5 -2
- package/skills/ultra-change/SKILL.md +38 -13
- package/skills/ultra-deliver/SKILL.md +23 -11
- package/skills/ultra-dev/SKILL.md +33 -6
- package/skills/ultra-doctor/SKILL.md +6 -2
- package/skills/ultra-init/SKILL.md +4 -2
- package/skills/ultra-plan/SKILL.md +68 -18
- package/skills/ultra-research/SKILL.md +45 -32
- package/skills/ultra-review/SKILL.md +9 -5
- package/skills/ultra-status/SKILL.md +9 -4
- package/skills/ultra-test/SKILL.md +15 -7
- package/skills/ultra-think/SKILL.md +6 -0
- package/skills/ultra-think/references/decision-dialogue.md +61 -14
- package/skills/ultra-verify/SKILL.md +4 -1
- package/spec/cli-protocol.md +19 -7
- package/spec/mcp-tools.yaml +531 -46
- package/spec/schemas/change-delta.v1.schema.json +153 -0
- package/spec/schemas/documentation-reconciliation.v1.schema.json +106 -0
- package/spec/schemas/mcp-tools.schema.json +1 -1
- package/spec/schemas/state-db.sql +57 -17
- package/spec/schemas/tasks.v4.5.schema.json +1 -1
- package/ultra-tools/cli.cjs +3 -2
- package/ultra-tools/commands/db.cjs +76 -25
- package/ultra-tools/commands/migrate.cjs +995 -80
- package/ultra-tools/commands/session.cjs +34 -8
- package/ultra-tools/commands/status.cjs +34 -7
- package/ultra-tools/commands/system.cjs +129 -22
- package/commands/learn.md +0 -10
- package/orchestrator/skill-miner.cjs +0 -133
- package/output-styles/coding-vibes.md +0 -67
- package/output-styles/structural-thinking.md +0 -61
- package/skills/learn/SKILL.md +0 -58
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
// State-operations layer over .ultra/state.db.
|
|
3
|
+
// State-operations layer over .ultra/.runtime/state.db.
|
|
4
4
|
//
|
|
5
5
|
// Every mutation in the Ultra Builder Pro runtime goes through this module.
|
|
6
6
|
// Callers (MCP server tool handlers, the migration CLI, the orchestrator)
|
|
@@ -57,6 +57,14 @@ const TASK_CONTRACT_PATCH_FIELDS = new Set([
|
|
|
57
57
|
const SESSION_PATCHABLE = Object.freeze([
|
|
58
58
|
'pid', 'status', 'lease_expires_at', 'heartbeat_at', 'worktree_path', 'artifact_dir',
|
|
59
59
|
]);
|
|
60
|
+
const SESSION_STATUSES = new Set(['running', 'completed', 'crashed', 'orphan']);
|
|
61
|
+
const SESSION_TERMINAL_STATUSES = new Set(['completed', 'crashed']);
|
|
62
|
+
const SESSION_STATUS_TRANSITIONS = Object.freeze({
|
|
63
|
+
running: new Set(['completed', 'crashed', 'orphan']),
|
|
64
|
+
orphan: new Set(['completed', 'crashed']),
|
|
65
|
+
completed: new Set(),
|
|
66
|
+
crashed: new Set(),
|
|
67
|
+
});
|
|
60
68
|
|
|
61
69
|
class StateOpsError extends Error {
|
|
62
70
|
constructor(code, message, { retriable = false, details } = {}) {
|
|
@@ -170,9 +178,44 @@ function normalizeContextRefs(value) {
|
|
|
170
178
|
if (!ref || !reason) {
|
|
171
179
|
throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}] requires ref and reason`);
|
|
172
180
|
}
|
|
173
|
-
|
|
174
|
-
|
|
181
|
+
const kind = String(item.kind || 'source').trim();
|
|
182
|
+
if (!['spec', 'source', 'test', 'docs', 'external'].includes(kind)) {
|
|
183
|
+
throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}].kind is unsupported`);
|
|
184
|
+
}
|
|
185
|
+
const expectedDigest = item.expected_digest ?? item.digest ?? null;
|
|
186
|
+
if (expectedDigest !== null && !/^[0-9a-f]{64}$/.test(String(expectedDigest))) {
|
|
187
|
+
throw new StateOpsError(
|
|
188
|
+
'VALIDATION_ERROR', `context_refs[${index}].expected_digest must be sha256`,
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
const freshnessPolicy = String(
|
|
192
|
+
item.freshness_policy || (expectedDigest ? 'digest' : 'existence'),
|
|
193
|
+
).trim();
|
|
194
|
+
if (!['digest', 'existence', 'advisory'].includes(freshnessPolicy)) {
|
|
195
|
+
throw new StateOpsError(
|
|
196
|
+
'VALIDATION_ERROR',
|
|
197
|
+
`context_refs[${index}].freshness_policy must be digest, existence, or advisory`,
|
|
198
|
+
);
|
|
199
|
+
}
|
|
200
|
+
const normalized = {
|
|
201
|
+
ref,
|
|
202
|
+
reason,
|
|
203
|
+
kind,
|
|
204
|
+
required: item.required !== false,
|
|
205
|
+
freshness_policy: freshnessPolicy,
|
|
175
206
|
};
|
|
207
|
+
if (expectedDigest !== null) normalized.expected_digest = String(expectedDigest);
|
|
208
|
+
if (item.anchor !== undefined) {
|
|
209
|
+
const anchor = String(item.anchor).trim();
|
|
210
|
+
if (!anchor) throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}].anchor cannot be empty`);
|
|
211
|
+
normalized.anchor = anchor;
|
|
212
|
+
}
|
|
213
|
+
if (item.scope !== undefined) {
|
|
214
|
+
const scope = String(item.scope).trim();
|
|
215
|
+
if (!scope) throw new StateOpsError('VALIDATION_ERROR', `context_refs[${index}].scope cannot be empty`);
|
|
216
|
+
normalized.scope = scope;
|
|
217
|
+
}
|
|
218
|
+
return normalized;
|
|
176
219
|
});
|
|
177
220
|
}
|
|
178
221
|
|
|
@@ -609,10 +652,32 @@ function createSession(db, { sid, task_id, runtime, pid = null, worktree_path, a
|
|
|
609
652
|
if (!readTask(db, task_id)) {
|
|
610
653
|
throw new StateOpsError('TASK_NOT_FOUND', `task ${task_id} does not exist`);
|
|
611
654
|
}
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
655
|
+
try {
|
|
656
|
+
db.prepare(
|
|
657
|
+
`INSERT INTO sessions (sid, task_id, runtime, pid, worktree_path, artifact_dir, status, lease_expires_at)
|
|
658
|
+
VALUES (?, ?, ?, ?, ?, ?, 'running', ?)`,
|
|
659
|
+
).run(sid, task_id, runtime, pid, worktree_path, artifact_dir, lease);
|
|
660
|
+
} catch (error) {
|
|
661
|
+
if (String(error?.code || '').startsWith('SQLITE_CONSTRAINT')) {
|
|
662
|
+
const conflict = db.prepare(
|
|
663
|
+
"SELECT sid, status FROM sessions WHERE task_id = ? AND status = 'running' LIMIT 1",
|
|
664
|
+
).get(task_id);
|
|
665
|
+
if (conflict) {
|
|
666
|
+
throw new StateOpsError(
|
|
667
|
+
'ADMISSION_DENIED',
|
|
668
|
+
`active session exists for task ${task_id} (${conflict.sid})`,
|
|
669
|
+
{
|
|
670
|
+
details: {
|
|
671
|
+
task_id,
|
|
672
|
+
conflict_sid: conflict.sid,
|
|
673
|
+
conflict_status: conflict.status,
|
|
674
|
+
},
|
|
675
|
+
},
|
|
676
|
+
);
|
|
677
|
+
}
|
|
678
|
+
}
|
|
679
|
+
throw error;
|
|
680
|
+
}
|
|
616
681
|
appendEventInTx(db, {
|
|
617
682
|
type: 'session_spawned',
|
|
618
683
|
task_id,
|
|
@@ -628,29 +693,104 @@ function updateSession(db, sid, patch = {}) {
|
|
|
628
693
|
return tx(db, () => {
|
|
629
694
|
const cur = db.prepare('SELECT * FROM sessions WHERE sid = ?').get(sid);
|
|
630
695
|
if (!cur) throw new StateOpsError('SESSION_NOT_FOUND', `no session ${sid}`);
|
|
696
|
+
const requestedStatus = patch.status;
|
|
697
|
+
if (requestedStatus !== undefined && !SESSION_STATUSES.has(requestedStatus)) {
|
|
698
|
+
throw new StateOpsError(
|
|
699
|
+
'VALIDATION_ERROR',
|
|
700
|
+
`unsupported session status: ${String(requestedStatus)}`,
|
|
701
|
+
);
|
|
702
|
+
}
|
|
703
|
+
if (requestedStatus !== undefined && requestedStatus !== cur.status) {
|
|
704
|
+
if (SESSION_TERMINAL_STATUSES.has(cur.status)) {
|
|
705
|
+
throw new StateOpsError(
|
|
706
|
+
'SESSION_TERMINAL_CONFLICT',
|
|
707
|
+
`session ${sid} is already ${cur.status} and cannot transition to ${requestedStatus}`,
|
|
708
|
+
{
|
|
709
|
+
details: {
|
|
710
|
+
sid,
|
|
711
|
+
current_status: cur.status,
|
|
712
|
+
requested_status: requestedStatus,
|
|
713
|
+
},
|
|
714
|
+
},
|
|
715
|
+
);
|
|
716
|
+
}
|
|
717
|
+
if (!SESSION_STATUS_TRANSITIONS[cur.status]?.has(requestedStatus)) {
|
|
718
|
+
throw new StateOpsError(
|
|
719
|
+
'SESSION_STATUS_TRANSITION_INVALID',
|
|
720
|
+
`session ${sid} cannot transition from ${cur.status} to ${requestedStatus}`,
|
|
721
|
+
{
|
|
722
|
+
details: {
|
|
723
|
+
sid,
|
|
724
|
+
current_status: cur.status,
|
|
725
|
+
requested_status: requestedStatus,
|
|
726
|
+
},
|
|
727
|
+
},
|
|
728
|
+
);
|
|
729
|
+
}
|
|
730
|
+
}
|
|
731
|
+
const normalizedPatch = (
|
|
732
|
+
SESSION_TERMINAL_STATUSES.has(requestedStatus)
|
|
733
|
+
)
|
|
734
|
+
? { ...patch, pid: null }
|
|
735
|
+
: patch;
|
|
631
736
|
const sets = [];
|
|
632
737
|
const params = [];
|
|
633
|
-
for (const key of Object.keys(
|
|
738
|
+
for (const key of Object.keys(normalizedPatch)) {
|
|
634
739
|
if (!SESSION_PATCHABLE.includes(key)) {
|
|
635
740
|
throw new StateOpsError('VALIDATION_ERROR', `session field ${key} is not patchable`);
|
|
636
741
|
}
|
|
637
742
|
sets.push(`${key} = ?`);
|
|
638
|
-
params.push(
|
|
743
|
+
params.push(normalizedPatch[key]);
|
|
639
744
|
}
|
|
640
745
|
if (sets.length === 0) return cur;
|
|
641
|
-
params.push(sid);
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
746
|
+
params.push(sid, cur.status);
|
|
747
|
+
const result = db.prepare(
|
|
748
|
+
`UPDATE sessions SET ${sets.join(', ')} WHERE sid = ? AND status = ?`,
|
|
749
|
+
).run(...params);
|
|
750
|
+
if (result.changes !== 1) {
|
|
751
|
+
const latest = db.prepare('SELECT status FROM sessions WHERE sid = ?').get(sid);
|
|
752
|
+
if (latest && SESSION_TERMINAL_STATUSES.has(latest.status)
|
|
753
|
+
&& requestedStatus !== undefined && latest.status !== requestedStatus) {
|
|
754
|
+
throw new StateOpsError(
|
|
755
|
+
'SESSION_TERMINAL_CONFLICT',
|
|
756
|
+
`session ${sid} is already ${latest.status} and cannot transition to ${requestedStatus}`,
|
|
757
|
+
{
|
|
758
|
+
details: {
|
|
759
|
+
sid,
|
|
760
|
+
current_status: latest.status,
|
|
761
|
+
requested_status: requestedStatus,
|
|
762
|
+
},
|
|
763
|
+
},
|
|
764
|
+
);
|
|
765
|
+
}
|
|
766
|
+
throw new StateOpsError(
|
|
767
|
+
'SESSION_STATUS_CONFLICT',
|
|
768
|
+
`session ${sid} changed while applying an update`,
|
|
769
|
+
{
|
|
770
|
+
retriable: true,
|
|
771
|
+
details: {
|
|
772
|
+
sid,
|
|
773
|
+
expected_status: cur.status,
|
|
774
|
+
actual_status: latest?.status || null,
|
|
775
|
+
},
|
|
776
|
+
},
|
|
777
|
+
);
|
|
778
|
+
}
|
|
779
|
+
if (requestedStatus && requestedStatus !== cur.status) {
|
|
780
|
+
const eventType = requestedStatus === 'completed' ? 'session_closed'
|
|
781
|
+
: requestedStatus === 'crashed' ? 'session_crashed'
|
|
782
|
+
: requestedStatus === 'orphan' ? 'session_orphaned'
|
|
647
783
|
: 'session_closed';
|
|
648
784
|
appendEventInTx(db, {
|
|
649
785
|
type: eventType,
|
|
650
786
|
task_id: cur.task_id,
|
|
651
787
|
session_id: sid,
|
|
652
788
|
runtime: cur.runtime,
|
|
653
|
-
payload: {
|
|
789
|
+
payload: {
|
|
790
|
+
from: cur.status,
|
|
791
|
+
to: requestedStatus,
|
|
792
|
+
worker_pid: cur.pid,
|
|
793
|
+
},
|
|
654
794
|
});
|
|
655
795
|
}
|
|
656
796
|
return db.prepare('SELECT * FROM sessions WHERE sid = ?').get(sid);
|
|
@@ -782,11 +922,15 @@ function admissionCheck(db, task_id, { freshnessSeconds = 120 } = {}) {
|
|
|
782
922
|
};
|
|
783
923
|
}
|
|
784
924
|
|
|
785
|
-
function reapOrphanSessions(db, {
|
|
925
|
+
function reapOrphanSessions(db, {
|
|
926
|
+
graceSeconds = 300,
|
|
927
|
+
exclude_session_ids = [],
|
|
928
|
+
} = {}) {
|
|
786
929
|
const cutoff = new Date(Date.now() - graceSeconds * 1000).toISOString();
|
|
930
|
+
const excluded = new Set(exclude_session_ids);
|
|
787
931
|
const candidates = db.prepare(
|
|
788
932
|
"SELECT * FROM sessions WHERE status = 'running' AND (lease_expires_at < ? OR heartbeat_at < ?)",
|
|
789
|
-
).all(cutoff, cutoff);
|
|
933
|
+
).all(cutoff, cutoff).filter((session) => !excluded.has(session.sid));
|
|
790
934
|
const reaped = [];
|
|
791
935
|
for (const s of candidates) {
|
|
792
936
|
updateSession(db, s.sid, { status: 'orphan' });
|
|
@@ -810,49 +954,103 @@ function readCircuitBreakerRow(db, task_id) {
|
|
|
810
954
|
return db.prepare('SELECT * FROM circuit_breaker WHERE task_id = ?').get(task_id) || null;
|
|
811
955
|
}
|
|
812
956
|
|
|
813
|
-
function
|
|
957
|
+
function recordTaskFailureInTx(db, task_id, {
|
|
814
958
|
reason = 'unknown',
|
|
815
959
|
session_id = null,
|
|
816
960
|
fail_threshold = DEFAULT_FAIL_THRESHOLD,
|
|
817
961
|
} = {}) {
|
|
818
962
|
if (!task_id) throw new StateOpsError('VALIDATION_ERROR', 'task_id required');
|
|
819
|
-
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
|
|
824
|
-
|
|
825
|
-
|
|
826
|
-
|
|
827
|
-
|
|
828
|
-
|
|
829
|
-
|
|
830
|
-
|
|
831
|
-
|
|
832
|
-
|
|
833
|
-
|
|
834
|
-
|
|
835
|
-
|
|
836
|
-
|
|
837
|
-
}
|
|
963
|
+
const now = nowIso();
|
|
964
|
+
const existing = readCircuitBreakerRow(db, task_id);
|
|
965
|
+
const wasTripped = !!(existing && existing.tripped_at);
|
|
966
|
+
const newCount = (existing ? existing.failure_count : 0) + 1;
|
|
967
|
+
const crossesThreshold = !wasTripped && newCount >= fail_threshold;
|
|
968
|
+
const trippedAt = wasTripped
|
|
969
|
+
? existing.tripped_at
|
|
970
|
+
: (crossesThreshold ? now : null);
|
|
971
|
+
|
|
972
|
+
if (existing) {
|
|
973
|
+
db.prepare(
|
|
974
|
+
'UPDATE circuit_breaker SET failure_count = ?, tripped_at = ?, last_failure_at = ?, last_failure_reason = ? WHERE task_id = ?',
|
|
975
|
+
).run(newCount, trippedAt, now, reason, task_id);
|
|
976
|
+
} else {
|
|
977
|
+
db.prepare(
|
|
978
|
+
'INSERT INTO circuit_breaker (task_id, failure_count, tripped_at, last_failure_at, last_failure_reason) VALUES (?, ?, ?, ?, ?)',
|
|
979
|
+
).run(task_id, newCount, trippedAt, now, reason);
|
|
980
|
+
}
|
|
838
981
|
|
|
982
|
+
appendEventInTx(db, {
|
|
983
|
+
type: 'task_failure',
|
|
984
|
+
task_id,
|
|
985
|
+
session_id,
|
|
986
|
+
payload: { reason, failure_count: newCount },
|
|
987
|
+
});
|
|
988
|
+
|
|
989
|
+
if (crossesThreshold) {
|
|
839
990
|
appendEventInTx(db, {
|
|
840
|
-
type: '
|
|
991
|
+
type: 'task_circuit_broken',
|
|
841
992
|
task_id,
|
|
842
993
|
session_id,
|
|
843
|
-
payload: {
|
|
994
|
+
payload: { failure_count: newCount, threshold: fail_threshold },
|
|
844
995
|
});
|
|
996
|
+
}
|
|
845
997
|
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
type: 'task_circuit_broken',
|
|
849
|
-
task_id,
|
|
850
|
-
session_id,
|
|
851
|
-
payload: { failure_count: newCount, threshold: fail_threshold },
|
|
852
|
-
});
|
|
853
|
-
}
|
|
998
|
+
return { failure_count: newCount, tripped: crossesThreshold || wasTripped };
|
|
999
|
+
}
|
|
854
1000
|
|
|
855
|
-
|
|
1001
|
+
function recordTaskFailure(db, task_id, options = {}) {
|
|
1002
|
+
return tx(db, () => recordTaskFailureInTx(db, task_id, options));
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
function crashOrphanSession(db, sid, {
|
|
1006
|
+
reason = 'session_crashed_on_boot',
|
|
1007
|
+
fail_threshold = DEFAULT_FAIL_THRESHOLD,
|
|
1008
|
+
} = {}) {
|
|
1009
|
+
if (!sid) throw new StateOpsError('VALIDATION_ERROR', 'sid required');
|
|
1010
|
+
return tx(db, () => {
|
|
1011
|
+
const session = db.prepare('SELECT * FROM sessions WHERE sid = ?').get(sid);
|
|
1012
|
+
if (!session) {
|
|
1013
|
+
throw new StateOpsError('SESSION_NOT_FOUND', `no session ${sid}`);
|
|
1014
|
+
}
|
|
1015
|
+
if (session.status !== 'orphan') {
|
|
1016
|
+
return {
|
|
1017
|
+
changed: false,
|
|
1018
|
+
status: session.status,
|
|
1019
|
+
failure_recorded: false,
|
|
1020
|
+
};
|
|
1021
|
+
}
|
|
1022
|
+
const result = db.prepare(
|
|
1023
|
+
"UPDATE sessions SET status = 'crashed', pid = NULL WHERE sid = ? AND status = 'orphan'",
|
|
1024
|
+
).run(sid);
|
|
1025
|
+
if (result.changes !== 1) {
|
|
1026
|
+
return {
|
|
1027
|
+
changed: false,
|
|
1028
|
+
status: db.prepare('SELECT status FROM sessions WHERE sid = ?').get(sid)?.status || null,
|
|
1029
|
+
failure_recorded: false,
|
|
1030
|
+
};
|
|
1031
|
+
}
|
|
1032
|
+
appendEventInTx(db, {
|
|
1033
|
+
type: 'session_crashed',
|
|
1034
|
+
task_id: session.task_id,
|
|
1035
|
+
session_id: sid,
|
|
1036
|
+
runtime: session.runtime,
|
|
1037
|
+
payload: {
|
|
1038
|
+
from: 'orphan',
|
|
1039
|
+
to: 'crashed',
|
|
1040
|
+
worker_pid: session.pid,
|
|
1041
|
+
},
|
|
1042
|
+
});
|
|
1043
|
+
const failure = recordTaskFailureInTx(db, session.task_id, {
|
|
1044
|
+
reason,
|
|
1045
|
+
session_id: sid,
|
|
1046
|
+
fail_threshold,
|
|
1047
|
+
});
|
|
1048
|
+
return {
|
|
1049
|
+
changed: true,
|
|
1050
|
+
status: 'crashed',
|
|
1051
|
+
failure_recorded: true,
|
|
1052
|
+
failure,
|
|
1053
|
+
};
|
|
856
1054
|
});
|
|
857
1055
|
}
|
|
858
1056
|
|
|
@@ -1004,6 +1202,7 @@ module.exports = {
|
|
|
1004
1202
|
heartbeatSession,
|
|
1005
1203
|
admissionCheck,
|
|
1006
1204
|
reapOrphanSessions,
|
|
1205
|
+
crashOrphanSession,
|
|
1007
1206
|
// circuit breaker
|
|
1008
1207
|
recordTaskFailure,
|
|
1009
1208
|
resetCircuitBreaker,
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// Phase 6.2 — Telemetry collector.
|
|
4
4
|
//
|
|
5
5
|
// Double-write model: state.db.telemetry (table, queryable via state-ops
|
|
6
|
-
// aggregations) + .ultra/telemetry/{YYYY-MM-DD}.jsonl (full payload, easy
|
|
6
|
+
// aggregations) + .ultra/.runtime/telemetry/{YYYY-MM-DD}.jsonl (full payload, easy
|
|
7
7
|
// grep, no schema constraint). The table is the authoritative source for
|
|
8
8
|
// cost panel queries; the jsonl is for human ops + audit trails + future
|
|
9
9
|
// Phase 6.5 SDK-usage correlation.
|
|
@@ -12,6 +12,7 @@ const fs = require('node:fs');
|
|
|
12
12
|
const path = require('node:path');
|
|
13
13
|
|
|
14
14
|
const { computeCost } = require('./pricing.cjs');
|
|
15
|
+
const runtimePaths = require('./runtime-paths.cjs');
|
|
15
16
|
const { isSupportedRuntime } = require('../../adapters/_shared/runtime-assets.cjs');
|
|
16
17
|
|
|
17
18
|
function todayStamp() {
|
|
@@ -19,7 +20,10 @@ function todayStamp() {
|
|
|
19
20
|
}
|
|
20
21
|
|
|
21
22
|
function telemetryJsonlPath(rootDir) {
|
|
22
|
-
return path.join(
|
|
23
|
+
return path.join(
|
|
24
|
+
runtimePaths.pathsFor(rootDir || '.').telemetryDir,
|
|
25
|
+
`${todayStamp()}.jsonl`,
|
|
26
|
+
);
|
|
23
27
|
}
|
|
24
28
|
|
|
25
29
|
function appendTelemetry(db, {
|
|
@@ -38,6 +42,11 @@ function appendTelemetry(db, {
|
|
|
38
42
|
throw new Error(`unsupported runtime: ${runtime}`);
|
|
39
43
|
}
|
|
40
44
|
if (!event_type) throw new Error('appendTelemetry: event_type required');
|
|
45
|
+
const layout = runtimePaths.validateProjectLayout(rootDir, {
|
|
46
|
+
env: process.env,
|
|
47
|
+
allowConfiguredRuntimeLink: true,
|
|
48
|
+
validateRuntimeTree: true,
|
|
49
|
+
});
|
|
41
50
|
|
|
42
51
|
// Auto-derive cost when tokens + runtime known and caller didn't supply one.
|
|
43
52
|
let effectiveCost = cost_usd;
|
|
@@ -50,7 +59,7 @@ function appendTelemetry(db, {
|
|
|
50
59
|
'INSERT INTO telemetry (session_id, event_type, tokens_input, tokens_output, tool_name, cost_usd, ts) VALUES (?, ?, ?, ?, ?, ?, ?)',
|
|
51
60
|
).run(session_id, event_type, tokens_input, tokens_output, tool_name, effectiveCost, now);
|
|
52
61
|
|
|
53
|
-
const jsonl =
|
|
62
|
+
const jsonl = path.join(layout.telemetryDir, `${todayStamp()}.jsonl`);
|
|
54
63
|
fs.mkdirSync(path.dirname(jsonl), { recursive: true });
|
|
55
64
|
fs.appendFileSync(jsonl, JSON.stringify({
|
|
56
65
|
id: Number(result.lastInsertRowid),
|