chainlesschain 0.45.66 → 0.45.70
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/package.json +1 -1
- package/src/gateways/ws/message-dispatcher.js +21 -0
- package/src/gateways/ws/session-protocol.js +1124 -0
- package/src/lib/abort-utils.js +20 -0
- package/src/lib/agent-core.js +83 -4
- package/src/lib/interaction-adapter.js +40 -21
- package/src/lib/sub-agent-registry.js +34 -0
- package/src/lib/ws-agent-handler.js +48 -0
- package/src/lib/ws-server.js +96 -0
- package/src/lib/ws-session-manager.js +608 -0
- package/src/runtime/coding-agent-events.cjs +45 -0
|
@@ -349,6 +349,10 @@ export class WSSessionManager {
|
|
|
349
349
|
planManager,
|
|
350
350
|
contextEngine,
|
|
351
351
|
permanentMemory,
|
|
352
|
+
reviewState: null,
|
|
353
|
+
pendingPatches: new Map(),
|
|
354
|
+
patchHistory: [],
|
|
355
|
+
taskGraph: null,
|
|
352
356
|
interaction: null, // Set by ws-server after creation
|
|
353
357
|
createdAt: new Date().toISOString(),
|
|
354
358
|
lastActivity: new Date().toISOString(),
|
|
@@ -456,6 +460,12 @@ export class WSSessionManager {
|
|
|
456
460
|
planManager,
|
|
457
461
|
contextEngine,
|
|
458
462
|
permanentMemory,
|
|
463
|
+
reviewState: metadata.reviewState || null,
|
|
464
|
+
pendingPatches: this._hydratePendingPatches(metadata.pendingPatches),
|
|
465
|
+
patchHistory: Array.isArray(metadata.patchHistory)
|
|
466
|
+
? metadata.patchHistory
|
|
467
|
+
: [],
|
|
468
|
+
taskGraph: this._hydrateTaskGraph(metadata.taskGraph),
|
|
459
469
|
interaction: null,
|
|
460
470
|
createdAt: dbSession.created_at,
|
|
461
471
|
lastActivity: new Date().toISOString(),
|
|
@@ -611,6 +621,583 @@ export class WSSessionManager {
|
|
|
611
621
|
return session;
|
|
612
622
|
}
|
|
613
623
|
|
|
624
|
+
/**
|
|
625
|
+
* Enter explicit review mode for a session. While in review, handlers
|
|
626
|
+
* MUST gate new sendMessage calls until the review is resolved. Reviewer
|
|
627
|
+
* sub-agents and human reviewers both feed into the same `comments` /
|
|
628
|
+
* `checklist` arrays.
|
|
629
|
+
*
|
|
630
|
+
* @param {string} sessionId
|
|
631
|
+
* @param {{
|
|
632
|
+
* reason?: string,
|
|
633
|
+
* requestedBy?: string,
|
|
634
|
+
* checklist?: Array<{ id?: string, title: string, note?: string }>,
|
|
635
|
+
* blocking?: boolean,
|
|
636
|
+
* }} [options]
|
|
637
|
+
*/
|
|
638
|
+
enterReview(sessionId, options = {}) {
|
|
639
|
+
const session = this.sessions.get(sessionId);
|
|
640
|
+
if (!session) return null;
|
|
641
|
+
|
|
642
|
+
// If already in pending review, return the existing state unchanged so
|
|
643
|
+
// callers can retry safely.
|
|
644
|
+
if (session.reviewState && session.reviewState.status === "pending") {
|
|
645
|
+
return session.reviewState;
|
|
646
|
+
}
|
|
647
|
+
|
|
648
|
+
const reviewId = `review-${this._generateId()}`;
|
|
649
|
+
const now = new Date().toISOString();
|
|
650
|
+
const checklist = Array.isArray(options.checklist)
|
|
651
|
+
? options.checklist.map((item, index) => ({
|
|
652
|
+
id: item.id || `chk-${index}-${Date.now()}`,
|
|
653
|
+
title: item.title || `Item ${index + 1}`,
|
|
654
|
+
note: item.note || null,
|
|
655
|
+
done: false,
|
|
656
|
+
}))
|
|
657
|
+
: [];
|
|
658
|
+
|
|
659
|
+
session.reviewState = {
|
|
660
|
+
reviewId,
|
|
661
|
+
status: "pending",
|
|
662
|
+
reason: options.reason || null,
|
|
663
|
+
requestedBy: options.requestedBy || "user",
|
|
664
|
+
requestedAt: now,
|
|
665
|
+
resolvedAt: null,
|
|
666
|
+
resolvedBy: null,
|
|
667
|
+
decision: null,
|
|
668
|
+
blocking: options.blocking !== false,
|
|
669
|
+
comments: [],
|
|
670
|
+
checklist,
|
|
671
|
+
};
|
|
672
|
+
session.lastActivity = now;
|
|
673
|
+
this._persistSessionState(sessionId);
|
|
674
|
+
return session.reviewState;
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
/**
|
|
678
|
+
* Submit an incremental update to the active review — append a comment
|
|
679
|
+
* and/or toggle a checklist item. Returns the updated reviewState, or null
|
|
680
|
+
* if the session has no active review.
|
|
681
|
+
*
|
|
682
|
+
* @param {string} sessionId
|
|
683
|
+
* @param {{
|
|
684
|
+
* comment?: { author?: string, content: string },
|
|
685
|
+
* checklistItemId?: string,
|
|
686
|
+
* checklistItemDone?: boolean,
|
|
687
|
+
* checklistItemNote?: string,
|
|
688
|
+
* }} update
|
|
689
|
+
*/
|
|
690
|
+
submitReviewComment(sessionId, update = {}) {
|
|
691
|
+
const session = this.sessions.get(sessionId);
|
|
692
|
+
if (!session || !session.reviewState) return null;
|
|
693
|
+
if (session.reviewState.status !== "pending") return null;
|
|
694
|
+
|
|
695
|
+
const now = new Date().toISOString();
|
|
696
|
+
|
|
697
|
+
if (update.comment && update.comment.content) {
|
|
698
|
+
session.reviewState.comments.push({
|
|
699
|
+
id: `cmt-${session.reviewState.comments.length}-${Date.now()}`,
|
|
700
|
+
author: update.comment.author || "user",
|
|
701
|
+
content: String(update.comment.content),
|
|
702
|
+
timestamp: now,
|
|
703
|
+
});
|
|
704
|
+
}
|
|
705
|
+
|
|
706
|
+
if (update.checklistItemId) {
|
|
707
|
+
const item = session.reviewState.checklist.find(
|
|
708
|
+
(c) => c.id === update.checklistItemId,
|
|
709
|
+
);
|
|
710
|
+
if (item) {
|
|
711
|
+
if (typeof update.checklistItemDone === "boolean") {
|
|
712
|
+
item.done = update.checklistItemDone;
|
|
713
|
+
}
|
|
714
|
+
if (typeof update.checklistItemNote === "string") {
|
|
715
|
+
item.note = update.checklistItemNote;
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
session.lastActivity = now;
|
|
721
|
+
this._persistSessionState(sessionId);
|
|
722
|
+
return session.reviewState;
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/**
|
|
726
|
+
* Resolve the active review with an approved/rejected decision. After
|
|
727
|
+
* resolve the session can accept new messages again (reviewState becomes
|
|
728
|
+
* non-blocking but is retained for audit).
|
|
729
|
+
*
|
|
730
|
+
* @param {string} sessionId
|
|
731
|
+
* @param {{ decision: "approved"|"rejected", resolvedBy?: string, summary?: string }} payload
|
|
732
|
+
*/
|
|
733
|
+
resolveReview(sessionId, payload = {}) {
|
|
734
|
+
const session = this.sessions.get(sessionId);
|
|
735
|
+
if (!session || !session.reviewState) return null;
|
|
736
|
+
if (session.reviewState.status !== "pending") {
|
|
737
|
+
return session.reviewState;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
const decision =
|
|
741
|
+
payload.decision === "approved" || payload.decision === "rejected"
|
|
742
|
+
? payload.decision
|
|
743
|
+
: "approved";
|
|
744
|
+
|
|
745
|
+
session.reviewState.status = decision;
|
|
746
|
+
session.reviewState.decision = decision;
|
|
747
|
+
session.reviewState.resolvedAt = new Date().toISOString();
|
|
748
|
+
session.reviewState.resolvedBy = payload.resolvedBy || "user";
|
|
749
|
+
session.reviewState.blocking = false;
|
|
750
|
+
if (payload.summary) {
|
|
751
|
+
session.reviewState.summary = String(payload.summary);
|
|
752
|
+
}
|
|
753
|
+
|
|
754
|
+
session.lastActivity = session.reviewState.resolvedAt;
|
|
755
|
+
this._persistSessionState(sessionId);
|
|
756
|
+
return session.reviewState;
|
|
757
|
+
}
|
|
758
|
+
|
|
759
|
+
/**
|
|
760
|
+
* Returns true when the session currently has a blocking review gate
|
|
761
|
+
* open. Callers (e.g. handleSessionMessage) should short-circuit with a
|
|
762
|
+
* REVIEW_BLOCKING error instead of running the agent turn.
|
|
763
|
+
*/
|
|
764
|
+
isReviewBlocking(sessionId) {
|
|
765
|
+
const session = this.sessions.get(sessionId);
|
|
766
|
+
if (!session || !session.reviewState) return false;
|
|
767
|
+
return (
|
|
768
|
+
session.reviewState.status === "pending" &&
|
|
769
|
+
session.reviewState.blocking === true
|
|
770
|
+
);
|
|
771
|
+
}
|
|
772
|
+
|
|
773
|
+
getReviewState(sessionId) {
|
|
774
|
+
const session = this.sessions.get(sessionId);
|
|
775
|
+
return session ? session.reviewState || null : null;
|
|
776
|
+
}
|
|
777
|
+
|
|
778
|
+
/**
|
|
779
|
+
* Record a proposed patch on the session. Accepts one or more file hunks
|
|
780
|
+
* that a tool wanted to write but should be previewed before they land.
|
|
781
|
+
*
|
|
782
|
+
* @param {string} sessionId
|
|
783
|
+
* @param {{
|
|
784
|
+
* files: Array<{
|
|
785
|
+
* path: string,
|
|
786
|
+
* op?: "create"|"modify"|"delete",
|
|
787
|
+
* before?: string|null,
|
|
788
|
+
* after?: string|null,
|
|
789
|
+
* diff?: string|null,
|
|
790
|
+
* stats?: { added?: number, removed?: number }
|
|
791
|
+
* }>,
|
|
792
|
+
* origin?: string,
|
|
793
|
+
* reason?: string,
|
|
794
|
+
* requestId?: string|null
|
|
795
|
+
* }} payload
|
|
796
|
+
* @returns {object|null} patch record, or null if the session is missing
|
|
797
|
+
*/
|
|
798
|
+
proposePatch(sessionId, payload = {}) {
|
|
799
|
+
const session = this.sessions.get(sessionId);
|
|
800
|
+
if (!session) return null;
|
|
801
|
+
|
|
802
|
+
const files = Array.isArray(payload.files) ? payload.files : [];
|
|
803
|
+
if (files.length === 0) return null;
|
|
804
|
+
|
|
805
|
+
const patchId = `patch-${this._generateId()}`;
|
|
806
|
+
const now = new Date().toISOString();
|
|
807
|
+
const normalizedFiles = files.map((file, index) => {
|
|
808
|
+
const op = file.op || (file.before == null ? "create" : "modify");
|
|
809
|
+
const stats = this._computePatchStats(file);
|
|
810
|
+
return {
|
|
811
|
+
index,
|
|
812
|
+
path: file.path || `unknown-${index}`,
|
|
813
|
+
op,
|
|
814
|
+
before: file.before == null ? null : String(file.before),
|
|
815
|
+
after: file.after == null ? null : String(file.after),
|
|
816
|
+
diff: file.diff == null ? null : String(file.diff),
|
|
817
|
+
stats,
|
|
818
|
+
};
|
|
819
|
+
});
|
|
820
|
+
|
|
821
|
+
const totalStats = normalizedFiles.reduce(
|
|
822
|
+
(acc, file) => ({
|
|
823
|
+
added: acc.added + (file.stats.added || 0),
|
|
824
|
+
removed: acc.removed + (file.stats.removed || 0),
|
|
825
|
+
}),
|
|
826
|
+
{ added: 0, removed: 0 },
|
|
827
|
+
);
|
|
828
|
+
|
|
829
|
+
const patch = {
|
|
830
|
+
patchId,
|
|
831
|
+
status: "pending",
|
|
832
|
+
origin: payload.origin || "tool",
|
|
833
|
+
reason: payload.reason || null,
|
|
834
|
+
requestId: payload.requestId || null,
|
|
835
|
+
proposedAt: now,
|
|
836
|
+
resolvedAt: null,
|
|
837
|
+
resolvedBy: null,
|
|
838
|
+
files: normalizedFiles,
|
|
839
|
+
stats: {
|
|
840
|
+
fileCount: normalizedFiles.length,
|
|
841
|
+
added: totalStats.added,
|
|
842
|
+
removed: totalStats.removed,
|
|
843
|
+
},
|
|
844
|
+
};
|
|
845
|
+
|
|
846
|
+
if (!(session.pendingPatches instanceof Map)) {
|
|
847
|
+
session.pendingPatches = new Map();
|
|
848
|
+
}
|
|
849
|
+
session.pendingPatches.set(patchId, patch);
|
|
850
|
+
session.lastActivity = now;
|
|
851
|
+
this._persistSessionState(sessionId);
|
|
852
|
+
return patch;
|
|
853
|
+
}
|
|
854
|
+
|
|
855
|
+
/**
|
|
856
|
+
* Mark a pending patch as applied. Moves the record to patchHistory so it
|
|
857
|
+
* is still visible in the summary view but no longer counts as pending.
|
|
858
|
+
*/
|
|
859
|
+
applyPatch(sessionId, patchId, options = {}) {
|
|
860
|
+
const session = this.sessions.get(sessionId);
|
|
861
|
+
if (!session || !(session.pendingPatches instanceof Map)) return null;
|
|
862
|
+
const patch = session.pendingPatches.get(patchId);
|
|
863
|
+
if (!patch) return null;
|
|
864
|
+
|
|
865
|
+
patch.status = "applied";
|
|
866
|
+
patch.resolvedAt = new Date().toISOString();
|
|
867
|
+
patch.resolvedBy = options.resolvedBy || "user";
|
|
868
|
+
if (options.note) {
|
|
869
|
+
patch.note = String(options.note);
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
session.pendingPatches.delete(patchId);
|
|
873
|
+
if (!Array.isArray(session.patchHistory)) {
|
|
874
|
+
session.patchHistory = [];
|
|
875
|
+
}
|
|
876
|
+
session.patchHistory.push(patch);
|
|
877
|
+
session.lastActivity = patch.resolvedAt;
|
|
878
|
+
this._persistSessionState(sessionId);
|
|
879
|
+
return patch;
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
/**
|
|
883
|
+
* Discard a pending patch. Same bookkeeping as applyPatch but records a
|
|
884
|
+
* "rejected" decision instead.
|
|
885
|
+
*/
|
|
886
|
+
rejectPatch(sessionId, patchId, options = {}) {
|
|
887
|
+
const session = this.sessions.get(sessionId);
|
|
888
|
+
if (!session || !(session.pendingPatches instanceof Map)) return null;
|
|
889
|
+
const patch = session.pendingPatches.get(patchId);
|
|
890
|
+
if (!patch) return null;
|
|
891
|
+
|
|
892
|
+
patch.status = "rejected";
|
|
893
|
+
patch.resolvedAt = new Date().toISOString();
|
|
894
|
+
patch.resolvedBy = options.resolvedBy || "user";
|
|
895
|
+
if (options.reason) {
|
|
896
|
+
patch.rejectionReason = String(options.reason);
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
session.pendingPatches.delete(patchId);
|
|
900
|
+
if (!Array.isArray(session.patchHistory)) {
|
|
901
|
+
session.patchHistory = [];
|
|
902
|
+
}
|
|
903
|
+
session.patchHistory.push(patch);
|
|
904
|
+
session.lastActivity = patch.resolvedAt;
|
|
905
|
+
this._persistSessionState(sessionId);
|
|
906
|
+
return patch;
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/**
|
|
910
|
+
* Return a flattened summary of all pending + resolved patches on the
|
|
911
|
+
* session. Shape matches what the renderer strip consumes:
|
|
912
|
+
* { pending: [...], history: [...], totals: { added, removed, fileCount } }
|
|
913
|
+
*/
|
|
914
|
+
getPatchSummary(sessionId) {
|
|
915
|
+
const session = this.sessions.get(sessionId);
|
|
916
|
+
if (!session) return null;
|
|
917
|
+
|
|
918
|
+
const pending =
|
|
919
|
+
session.pendingPatches instanceof Map
|
|
920
|
+
? Array.from(session.pendingPatches.values())
|
|
921
|
+
: [];
|
|
922
|
+
const history = Array.isArray(session.patchHistory)
|
|
923
|
+
? session.patchHistory
|
|
924
|
+
: [];
|
|
925
|
+
|
|
926
|
+
const totals = [...pending, ...history].reduce(
|
|
927
|
+
(acc, patch) => ({
|
|
928
|
+
fileCount: acc.fileCount + (patch.stats?.fileCount || 0),
|
|
929
|
+
added: acc.added + (patch.stats?.added || 0),
|
|
930
|
+
removed: acc.removed + (patch.stats?.removed || 0),
|
|
931
|
+
}),
|
|
932
|
+
{ fileCount: 0, added: 0, removed: 0 },
|
|
933
|
+
);
|
|
934
|
+
|
|
935
|
+
return { pending, history, totals };
|
|
936
|
+
}
|
|
937
|
+
|
|
938
|
+
hasPendingPatches(sessionId) {
|
|
939
|
+
const session = this.sessions.get(sessionId);
|
|
940
|
+
if (!session || !(session.pendingPatches instanceof Map)) return false;
|
|
941
|
+
return session.pendingPatches.size > 0;
|
|
942
|
+
}
|
|
943
|
+
|
|
944
|
+
_computePatchStats(file) {
|
|
945
|
+
if (file && file.stats && typeof file.stats === "object") {
|
|
946
|
+
return {
|
|
947
|
+
added: Number(file.stats.added) || 0,
|
|
948
|
+
removed: Number(file.stats.removed) || 0,
|
|
949
|
+
};
|
|
950
|
+
}
|
|
951
|
+
const before = file && typeof file.before === "string" ? file.before : "";
|
|
952
|
+
const after = file && typeof file.after === "string" ? file.after : "";
|
|
953
|
+
const beforeLines = before ? before.split(/\r?\n/).length : 0;
|
|
954
|
+
const afterLines = after ? after.split(/\r?\n/).length : 0;
|
|
955
|
+
// Rough heuristic when no explicit diff is provided: full replace counts
|
|
956
|
+
// the entire file as added/removed.
|
|
957
|
+
if (!before && after) return { added: afterLines, removed: 0 };
|
|
958
|
+
if (before && !after) return { added: 0, removed: beforeLines };
|
|
959
|
+
return {
|
|
960
|
+
added: Math.max(0, afterLines - beforeLines),
|
|
961
|
+
removed: Math.max(0, beforeLines - afterLines),
|
|
962
|
+
};
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
/**
|
|
966
|
+
* Create or replace the task graph for a session. A graph is a DAG of
|
|
967
|
+
* `nodes` keyed by id; each node has `{ id, title, status, dependsOn[],
|
|
968
|
+
* metadata }`. Returns the serialized graph.
|
|
969
|
+
*/
|
|
970
|
+
createTaskGraph(sessionId, payload = {}) {
|
|
971
|
+
const session = this.sessions.get(sessionId);
|
|
972
|
+
if (!session) return null;
|
|
973
|
+
|
|
974
|
+
const graphId = payload.graphId || `graph-${this._generateId()}`;
|
|
975
|
+
const now = new Date().toISOString();
|
|
976
|
+
const nodes = {};
|
|
977
|
+
const incomingNodes = Array.isArray(payload.nodes) ? payload.nodes : [];
|
|
978
|
+
for (const raw of incomingNodes) {
|
|
979
|
+
if (!raw || !raw.id) continue;
|
|
980
|
+
nodes[raw.id] = this._normalizeTaskNode(raw, now);
|
|
981
|
+
}
|
|
982
|
+
|
|
983
|
+
const graph = {
|
|
984
|
+
graphId,
|
|
985
|
+
title: payload.title || null,
|
|
986
|
+
description: payload.description || null,
|
|
987
|
+
status: "active",
|
|
988
|
+
createdAt: now,
|
|
989
|
+
updatedAt: now,
|
|
990
|
+
completedAt: null,
|
|
991
|
+
nodes,
|
|
992
|
+
order: Object.keys(nodes),
|
|
993
|
+
};
|
|
994
|
+
|
|
995
|
+
session.taskGraph = graph;
|
|
996
|
+
session.lastActivity = now;
|
|
997
|
+
this._persistSessionState(sessionId);
|
|
998
|
+
return this._cloneTaskGraph(graph);
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
/**
|
|
1002
|
+
* Add a node to the existing task graph. Fails if no graph exists or if
|
|
1003
|
+
* the node id already exists.
|
|
1004
|
+
*/
|
|
1005
|
+
addTaskNode(sessionId, payload = {}) {
|
|
1006
|
+
const session = this.sessions.get(sessionId);
|
|
1007
|
+
if (!session || !session.taskGraph) return null;
|
|
1008
|
+
if (!payload || !payload.id) return null;
|
|
1009
|
+
const graph = session.taskGraph;
|
|
1010
|
+
if (graph.nodes[payload.id]) return null;
|
|
1011
|
+
|
|
1012
|
+
const now = new Date().toISOString();
|
|
1013
|
+
graph.nodes[payload.id] = this._normalizeTaskNode(payload, now);
|
|
1014
|
+
graph.order = [...(graph.order || []), payload.id];
|
|
1015
|
+
graph.updatedAt = now;
|
|
1016
|
+
session.lastActivity = now;
|
|
1017
|
+
this._persistSessionState(sessionId);
|
|
1018
|
+
return this._cloneTaskGraph(graph);
|
|
1019
|
+
}
|
|
1020
|
+
|
|
1021
|
+
/**
|
|
1022
|
+
* Update a node's status / metadata. Valid statuses: pending, ready,
|
|
1023
|
+
* running, completed, failed, skipped.
|
|
1024
|
+
*/
|
|
1025
|
+
updateTaskNode(sessionId, nodeId, updates = {}) {
|
|
1026
|
+
const session = this.sessions.get(sessionId);
|
|
1027
|
+
if (!session || !session.taskGraph) return null;
|
|
1028
|
+
const graph = session.taskGraph;
|
|
1029
|
+
const node = graph.nodes[nodeId];
|
|
1030
|
+
if (!node) return null;
|
|
1031
|
+
|
|
1032
|
+
const now = new Date().toISOString();
|
|
1033
|
+
if (updates.status) {
|
|
1034
|
+
node.status = String(updates.status);
|
|
1035
|
+
if (node.status === "running" && !node.startedAt) {
|
|
1036
|
+
node.startedAt = now;
|
|
1037
|
+
}
|
|
1038
|
+
if (
|
|
1039
|
+
node.status === "completed" ||
|
|
1040
|
+
node.status === "failed" ||
|
|
1041
|
+
node.status === "skipped"
|
|
1042
|
+
) {
|
|
1043
|
+
node.completedAt = now;
|
|
1044
|
+
}
|
|
1045
|
+
}
|
|
1046
|
+
if (updates.title !== undefined) node.title = updates.title;
|
|
1047
|
+
if (updates.result !== undefined) node.result = updates.result;
|
|
1048
|
+
if (updates.error !== undefined) node.error = updates.error;
|
|
1049
|
+
if (updates.metadata !== undefined) {
|
|
1050
|
+
node.metadata = { ...(node.metadata || {}), ...(updates.metadata || {}) };
|
|
1051
|
+
}
|
|
1052
|
+
node.updatedAt = now;
|
|
1053
|
+
graph.updatedAt = now;
|
|
1054
|
+
|
|
1055
|
+
// Check graph completion
|
|
1056
|
+
const allDone = Object.values(graph.nodes).every((n) =>
|
|
1057
|
+
["completed", "failed", "skipped"].includes(n.status),
|
|
1058
|
+
);
|
|
1059
|
+
if (allDone) {
|
|
1060
|
+
graph.status = Object.values(graph.nodes).some(
|
|
1061
|
+
(n) => n.status === "failed",
|
|
1062
|
+
)
|
|
1063
|
+
? "failed"
|
|
1064
|
+
: "completed";
|
|
1065
|
+
graph.completedAt = now;
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
session.lastActivity = now;
|
|
1069
|
+
this._persistSessionState(sessionId);
|
|
1070
|
+
return this._cloneTaskGraph(graph);
|
|
1071
|
+
}
|
|
1072
|
+
|
|
1073
|
+
/**
|
|
1074
|
+
* Advance the task graph: mark any `pending` node whose dependencies are
|
|
1075
|
+
* all `completed` (or `skipped`) as `ready`. Returns the list of node ids
|
|
1076
|
+
* that became ready and the updated graph snapshot.
|
|
1077
|
+
*/
|
|
1078
|
+
advanceTaskGraph(sessionId) {
|
|
1079
|
+
const session = this.sessions.get(sessionId);
|
|
1080
|
+
if (!session || !session.taskGraph) return null;
|
|
1081
|
+
const graph = session.taskGraph;
|
|
1082
|
+
|
|
1083
|
+
const becameReady = [];
|
|
1084
|
+
for (const node of Object.values(graph.nodes)) {
|
|
1085
|
+
if (node.status !== "pending") continue;
|
|
1086
|
+
const deps = Array.isArray(node.dependsOn) ? node.dependsOn : [];
|
|
1087
|
+
const blocked = deps.some((depId) => {
|
|
1088
|
+
const dep = graph.nodes[depId];
|
|
1089
|
+
if (!dep) return true;
|
|
1090
|
+
return dep.status !== "completed" && dep.status !== "skipped";
|
|
1091
|
+
});
|
|
1092
|
+
if (!blocked) {
|
|
1093
|
+
node.status = "ready";
|
|
1094
|
+
node.updatedAt = new Date().toISOString();
|
|
1095
|
+
becameReady.push(node.id);
|
|
1096
|
+
}
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
if (becameReady.length > 0) {
|
|
1100
|
+
graph.updatedAt = new Date().toISOString();
|
|
1101
|
+
session.lastActivity = graph.updatedAt;
|
|
1102
|
+
this._persistSessionState(sessionId);
|
|
1103
|
+
}
|
|
1104
|
+
|
|
1105
|
+
return {
|
|
1106
|
+
graph: this._cloneTaskGraph(graph),
|
|
1107
|
+
becameReady,
|
|
1108
|
+
};
|
|
1109
|
+
}
|
|
1110
|
+
|
|
1111
|
+
getTaskGraph(sessionId) {
|
|
1112
|
+
const session = this.sessions.get(sessionId);
|
|
1113
|
+
if (!session || !session.taskGraph) return null;
|
|
1114
|
+
return this._cloneTaskGraph(session.taskGraph);
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
clearTaskGraph(sessionId) {
|
|
1118
|
+
const session = this.sessions.get(sessionId);
|
|
1119
|
+
if (!session) return false;
|
|
1120
|
+
session.taskGraph = null;
|
|
1121
|
+
session.lastActivity = new Date().toISOString();
|
|
1122
|
+
this._persistSessionState(sessionId);
|
|
1123
|
+
return true;
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
_normalizeTaskNode(raw, now) {
|
|
1127
|
+
const status = raw.status || "pending";
|
|
1128
|
+
return {
|
|
1129
|
+
id: raw.id,
|
|
1130
|
+
title: raw.title || raw.id,
|
|
1131
|
+
description: raw.description || null,
|
|
1132
|
+
status,
|
|
1133
|
+
dependsOn: Array.isArray(raw.dependsOn)
|
|
1134
|
+
? raw.dependsOn.filter((x) => typeof x === "string")
|
|
1135
|
+
: [],
|
|
1136
|
+
metadata:
|
|
1137
|
+
raw.metadata && typeof raw.metadata === "object" ? raw.metadata : {},
|
|
1138
|
+
createdAt: raw.createdAt || now,
|
|
1139
|
+
updatedAt: raw.updatedAt || now,
|
|
1140
|
+
startedAt: raw.startedAt || null,
|
|
1141
|
+
completedAt: raw.completedAt || null,
|
|
1142
|
+
result: raw.result || null,
|
|
1143
|
+
error: raw.error || null,
|
|
1144
|
+
};
|
|
1145
|
+
}
|
|
1146
|
+
|
|
1147
|
+
_cloneTaskGraph(graph) {
|
|
1148
|
+
if (!graph) return null;
|
|
1149
|
+
return {
|
|
1150
|
+
graphId: graph.graphId,
|
|
1151
|
+
title: graph.title,
|
|
1152
|
+
description: graph.description,
|
|
1153
|
+
status: graph.status,
|
|
1154
|
+
createdAt: graph.createdAt,
|
|
1155
|
+
updatedAt: graph.updatedAt,
|
|
1156
|
+
completedAt: graph.completedAt,
|
|
1157
|
+
order: Array.isArray(graph.order)
|
|
1158
|
+
? [...graph.order]
|
|
1159
|
+
: Object.keys(graph.nodes || {}),
|
|
1160
|
+
nodes: Object.fromEntries(
|
|
1161
|
+
Object.entries(graph.nodes || {}).map(([id, node]) => [
|
|
1162
|
+
id,
|
|
1163
|
+
{
|
|
1164
|
+
...node,
|
|
1165
|
+
dependsOn: [...(node.dependsOn || [])],
|
|
1166
|
+
metadata: { ...(node.metadata || {}) },
|
|
1167
|
+
},
|
|
1168
|
+
]),
|
|
1169
|
+
),
|
|
1170
|
+
};
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
_hydrateTaskGraph(data) {
|
|
1174
|
+
if (!data || typeof data !== "object") return null;
|
|
1175
|
+
if (!data.graphId || !data.nodes) return null;
|
|
1176
|
+
const nodes = {};
|
|
1177
|
+
for (const [id, node] of Object.entries(data.nodes)) {
|
|
1178
|
+
nodes[id] = this._normalizeTaskNode(
|
|
1179
|
+
{ ...node, id },
|
|
1180
|
+
node.createdAt || new Date().toISOString(),
|
|
1181
|
+
);
|
|
1182
|
+
}
|
|
1183
|
+
return {
|
|
1184
|
+
graphId: data.graphId,
|
|
1185
|
+
title: data.title || null,
|
|
1186
|
+
description: data.description || null,
|
|
1187
|
+
status: data.status || "active",
|
|
1188
|
+
createdAt: data.createdAt || new Date().toISOString(),
|
|
1189
|
+
updatedAt: data.updatedAt || new Date().toISOString(),
|
|
1190
|
+
completedAt: data.completedAt || null,
|
|
1191
|
+
order: Array.isArray(data.order) ? data.order : Object.keys(nodes),
|
|
1192
|
+
nodes,
|
|
1193
|
+
};
|
|
1194
|
+
}
|
|
1195
|
+
|
|
1196
|
+
_serializeTaskGraph(graph) {
|
|
1197
|
+
if (!graph) return null;
|
|
1198
|
+
return this._cloneTaskGraph(graph);
|
|
1199
|
+
}
|
|
1200
|
+
|
|
614
1201
|
/**
|
|
615
1202
|
* Persist current messages for a session.
|
|
616
1203
|
*/
|
|
@@ -689,9 +1276,30 @@ export class WSSessionManager {
|
|
|
689
1276
|
worktreeIsolation: session.worktreeIsolation === true,
|
|
690
1277
|
worktree: session.worktree || null,
|
|
691
1278
|
planSnapshot: this._serializePlanManager(session.planManager),
|
|
1279
|
+
reviewState: session.reviewState || null,
|
|
1280
|
+
pendingPatches:
|
|
1281
|
+
session.pendingPatches instanceof Map
|
|
1282
|
+
? Array.from(session.pendingPatches.values())
|
|
1283
|
+
: [],
|
|
1284
|
+
patchHistory: Array.isArray(session.patchHistory)
|
|
1285
|
+
? session.patchHistory
|
|
1286
|
+
: [],
|
|
1287
|
+
taskGraph: this._serializeTaskGraph(session.taskGraph),
|
|
692
1288
|
};
|
|
693
1289
|
}
|
|
694
1290
|
|
|
1291
|
+
_hydratePendingPatches(list) {
|
|
1292
|
+
const map = new Map();
|
|
1293
|
+
if (Array.isArray(list)) {
|
|
1294
|
+
for (const patch of list) {
|
|
1295
|
+
if (patch && patch.patchId) {
|
|
1296
|
+
map.set(patch.patchId, patch);
|
|
1297
|
+
}
|
|
1298
|
+
}
|
|
1299
|
+
}
|
|
1300
|
+
return map;
|
|
1301
|
+
}
|
|
1302
|
+
|
|
695
1303
|
_serializePlanManager(planManager) {
|
|
696
1304
|
if (!planManager) {
|
|
697
1305
|
return null;
|
|
@@ -78,6 +78,50 @@ const CODING_AGENT_EVENT_TYPES = Object.freeze({
|
|
|
78
78
|
SERVER_STARTING: "runtime.server.starting",
|
|
79
79
|
SERVER_READY: "runtime.server.ready",
|
|
80
80
|
SERVER_STOPPED: "runtime.server.stopped",
|
|
81
|
+
|
|
82
|
+
// Sub-agent delegation — lifecycle of child agents spawned from a parent
|
|
83
|
+
// session via the spawn_sub_agent tool. Sequence within a parent requestId
|
|
84
|
+
// stays strictly increasing; parent session + sub-agent id are carried in
|
|
85
|
+
// the payload so UIs can group child events under the parent turn.
|
|
86
|
+
SUB_AGENT_STARTED: "sub-agent.started",
|
|
87
|
+
SUB_AGENT_PROGRESS: "sub-agent.progress",
|
|
88
|
+
SUB_AGENT_COMPLETED: "sub-agent.completed",
|
|
89
|
+
SUB_AGENT_FAILED: "sub-agent.failed",
|
|
90
|
+
SUB_AGENT_LIST: "sub-agent.list",
|
|
91
|
+
|
|
92
|
+
// Review mode — explicit human-in-the-loop (or reviewer sub-agent) gate.
|
|
93
|
+
// When a session enters review mode the runtime MUST block sendMessage
|
|
94
|
+
// until the review is resolved (approved / rejected). Comments can be
|
|
95
|
+
// submitted incrementally by either human reviewers via the UI or by a
|
|
96
|
+
// reviewer role sub-agent writing async findings back to the parent
|
|
97
|
+
// session.
|
|
98
|
+
REVIEW_REQUESTED: "review.requested",
|
|
99
|
+
REVIEW_UPDATED: "review.updated",
|
|
100
|
+
REVIEW_RESOLVED: "review.resolved",
|
|
101
|
+
REVIEW_STATE: "review.state",
|
|
102
|
+
|
|
103
|
+
// Patch preview / diff summary — proposed file edits that the user can
|
|
104
|
+
// preview, approve (apply) or reject before they land on disk. Used to
|
|
105
|
+
// surface a "diff summary" strip in the UI that batches multiple writes
|
|
106
|
+
// from a single turn into a reviewable hunk list.
|
|
107
|
+
PATCH_PROPOSED: "patch.proposed",
|
|
108
|
+
PATCH_APPLIED: "patch.applied",
|
|
109
|
+
PATCH_REJECTED: "patch.rejected",
|
|
110
|
+
PATCH_SUMMARY: "patch.summary",
|
|
111
|
+
|
|
112
|
+
// Persistent task graph + orchestrator — a session-scoped DAG of tasks
|
|
113
|
+
// with dependencies. The runtime serializes the graph to session metadata
|
|
114
|
+
// so it survives CLI restarts; the orchestrator advances the graph by
|
|
115
|
+
// marking ready nodes as `running` when their dependencies complete.
|
|
116
|
+
TASK_GRAPH_CREATED: "task-graph.created",
|
|
117
|
+
TASK_GRAPH_UPDATED: "task-graph.updated",
|
|
118
|
+
TASK_GRAPH_NODE_ADDED: "task-graph.node.added",
|
|
119
|
+
TASK_GRAPH_NODE_UPDATED: "task-graph.node.updated",
|
|
120
|
+
TASK_GRAPH_NODE_COMPLETED: "task-graph.node.completed",
|
|
121
|
+
TASK_GRAPH_NODE_FAILED: "task-graph.node.failed",
|
|
122
|
+
TASK_GRAPH_ADVANCED: "task-graph.advanced",
|
|
123
|
+
TASK_GRAPH_COMPLETED: "task-graph.completed",
|
|
124
|
+
TASK_GRAPH_STATE: "task-graph.state",
|
|
81
125
|
});
|
|
82
126
|
|
|
83
127
|
const VALID_TYPE_SET = new Set(Object.values(CODING_AGENT_EVENT_TYPES));
|
|
@@ -330,6 +374,7 @@ const CodingAgentEventType = Object.freeze({
|
|
|
330
374
|
SERVER_STOPPED: CODING_AGENT_EVENT_TYPES.SERVER_STOPPED,
|
|
331
375
|
SESSION_CREATED: CODING_AGENT_EVENT_TYPES.SESSION_STARTED,
|
|
332
376
|
SESSION_RESUMED: CODING_AGENT_EVENT_TYPES.SESSION_RESUMED,
|
|
377
|
+
SESSION_INTERRUPTED: CODING_AGENT_EVENT_TYPES.SESSION_INTERRUPTED,
|
|
333
378
|
SESSION_CLOSED: CODING_AGENT_EVENT_TYPES.SESSION_CLOSED,
|
|
334
379
|
SESSION_LIST: CODING_AGENT_EVENT_TYPES.SESSION_LIST,
|
|
335
380
|
WORKTREE_LIST: CODING_AGENT_EVENT_TYPES.WORKTREE_LIST,
|