@virsanghavi/axis-server 1.7.1 → 1.8.0

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.
@@ -51,7 +51,13 @@ Skip jobs ONLY for: single-line fixes, typos, config tweaks.
51
51
  - Release locks IMMEDIATELY by completing jobs. Never hold a lock while doing unrelated work.
52
52
  - `force_unlock` is a **last resort** — only for locks >25 min old from a crashed agent. Always give a reason.
53
53
 
54
+ ### Releasing Locks (CRITICAL — do not skip)
55
+ **Every file you lock MUST be unlocked before your session ends.** Dangling locks block every other agent in the project.
56
+ - **Primary unlock method**: `complete_job` — releases all locks for that job.
57
+ - **Session end**: `finalize_session` — clears ALL remaining locks. Call this before you stop responding.
58
+ - **Self-check**: Before finishing, verify: "Have I completed all jobs and called `finalize_session`?" If not, do it now.
59
+
54
60
  ### Session Cleanup (MANDATORY)
55
- - `complete_job` after EVERY finished task — do not accumulate incomplete jobs.
61
+ - `complete_job` after EVERY finished task — do not accumulate incomplete jobs. **This is how locks get released.**
56
62
  - `update_shared_context` after meaningful steps — log decisions, not just actions.
57
- - `finalize_session` when the user's request is fully complete — this is required, not optional.
63
+ - `finalize_session` when the user's request is fully complete — this is required, not optional. **This clears all remaining locks.**
@@ -284,7 +284,7 @@ var CircuitOpenError = class extends Error {
284
284
  this.name = "CircuitOpenError";
285
285
  }
286
286
  };
287
- var NerveCenter = class {
287
+ var NerveCenter = class _NerveCenter {
288
288
  mutex;
289
289
  state;
290
290
  contextManager;
@@ -876,6 +876,38 @@ ${notepad}`;
876
876
  }
877
877
  }
878
878
  // --- Decision & Orchestration ---
879
+ /**
880
+ * Check if two file paths conflict hierarchically.
881
+ * A lock on a directory blocks locks on any file within it, and vice versa.
882
+ * Examples:
883
+ * pathsConflict("/foo/bar", "/foo/bar/baz.ts") => true (parent blocks child)
884
+ * pathsConflict("/foo/bar/baz.ts", "/foo/bar") => true (child blocks parent)
885
+ * pathsConflict("/foo/bar", "/foo/bar") => true (exact match)
886
+ * pathsConflict("/foo/bar", "/foo/baz") => false (siblings)
887
+ */
888
+ static pathsConflict(pathA, pathB) {
889
+ const a = pathA.replace(/\/+$/, "");
890
+ const b = pathB.replace(/\/+$/, "");
891
+ if (a === b) return true;
892
+ if (b.startsWith(a + "/")) return true;
893
+ if (a.startsWith(b + "/")) return true;
894
+ return false;
895
+ }
896
+ /**
897
+ * Find any existing lock that conflicts hierarchically with the requested path.
898
+ * Skips locks owned by the same agent and stale locks.
899
+ */
900
+ findHierarchicalConflict(requestedPath, requestingAgent, locks) {
901
+ for (const lock of locks) {
902
+ if (lock.agentId === requestingAgent) continue;
903
+ const isStale = Date.now() - lock.timestamp > this.lockTimeout;
904
+ if (isStale) continue;
905
+ if (_NerveCenter.pathsConflict(requestedPath, lock.filePath)) {
906
+ return lock;
907
+ }
908
+ }
909
+ return null;
910
+ }
879
911
  async proposeFileAccess(agentId, filePath, intent, userPrompt) {
880
912
  return await this.mutex.runExclusive(async () => {
881
913
  logger.info(`[proposeFileAccess] Starting - agentId: ${agentId}, filePath: ${filePath}`);
@@ -906,6 +938,16 @@ ${notepad}`;
906
938
  } catch (e) {
907
939
  if (e.message && e.message.includes("409")) {
908
940
  logger.info(`[proposeFileAccess] Lock conflict (409)`);
941
+ let blockingAgent;
942
+ try {
943
+ const jsonMatch = e.message.match(/\{.*\}/s);
944
+ if (jsonMatch) {
945
+ const parsed = JSON.parse(jsonMatch[0]);
946
+ blockingAgent = parsed.current_lock?.agent_id;
947
+ }
948
+ } catch {
949
+ }
950
+ this.logLockEvent("BLOCKED", filePath, agentId, blockingAgent, intent);
909
951
  return {
910
952
  status: "REQUIRES_ORCHESTRATION",
911
953
  message: `File '${filePath}' is locked by another agent`
@@ -917,6 +959,26 @@ ${notepad}`;
917
959
  }
918
960
  if (this.useSupabase && this.supabase && this._projectId) {
919
961
  try {
962
+ const { data: existingLocks } = await this.supabase.from("locks").select("agent_id, file_path, intent, updated_at").eq("project_id", this._projectId);
963
+ if (existingLocks && existingLocks.length > 0) {
964
+ const asFileLocks = existingLocks.map((row2) => ({
965
+ agentId: row2.agent_id,
966
+ filePath: row2.file_path,
967
+ intent: row2.intent,
968
+ userPrompt: "",
969
+ timestamp: row2.updated_at ? Date.parse(row2.updated_at) : Date.now()
970
+ }));
971
+ const conflict2 = this.findHierarchicalConflict(filePath, agentId, asFileLocks);
972
+ if (conflict2) {
973
+ logger.info(`[proposeFileAccess] Hierarchical conflict: '${filePath}' overlaps with locked '${conflict2.filePath}' (owner: ${conflict2.agentId})`);
974
+ this.logLockEvent("BLOCKED", filePath, agentId, conflict2.agentId, intent);
975
+ return {
976
+ status: "REQUIRES_ORCHESTRATION",
977
+ message: `Conflict: '${filePath}' overlaps with '${conflict2.filePath}' locked by '${conflict2.agentId}'`,
978
+ currentLock: conflict2
979
+ };
980
+ }
981
+ }
920
982
  const { data, error } = await this.supabase.rpc("try_acquire_lock", {
921
983
  p_project_id: this._projectId,
922
984
  p_file_path: filePath,
@@ -949,17 +1011,16 @@ ${notepad}`;
949
1011
  logger.warn("[NerveCenter] Lock RPC failed. Falling back to local.", e);
950
1012
  }
951
1013
  }
952
- const existing = Object.values(this.state.locks).find((l) => l.filePath === filePath);
953
- if (existing) {
954
- const isStale = Date.now() - existing.timestamp > this.lockTimeout;
955
- if (!isStale && existing.agentId !== agentId) {
956
- this.logLockEvent("BLOCKED", filePath, agentId, existing.agentId, intent);
957
- return {
958
- status: "REQUIRES_ORCHESTRATION",
959
- message: `Conflict: File '${filePath}' is currently locked by '${existing.agentId}'`,
960
- currentLock: existing
961
- };
962
- }
1014
+ const allLocks = Object.values(this.state.locks);
1015
+ const conflict = this.findHierarchicalConflict(filePath, agentId, allLocks);
1016
+ if (conflict) {
1017
+ logger.info(`[proposeFileAccess] Hierarchical conflict (local): '${filePath}' overlaps with locked '${conflict.filePath}' (owner: ${conflict.agentId})`);
1018
+ this.logLockEvent("BLOCKED", filePath, agentId, conflict.agentId, intent);
1019
+ return {
1020
+ status: "REQUIRES_ORCHESTRATION",
1021
+ message: `Conflict: '${filePath}' overlaps with '${conflict.filePath}' locked by '${conflict.agentId}'`,
1022
+ currentLock: conflict
1023
+ };
963
1024
  }
964
1025
  this.state.locks[filePath] = { agentId, filePath, intent, userPrompt, timestamp: Date.now() };
965
1026
  await this.saveState();
@@ -1785,10 +1846,16 @@ Agents MUST execute the full Axis workflow without being told:
1785
1846
  Every non-trivial task MUST follow: post_job \u2192 claim_next_job \u2192 propose_file_access \u2192 (edit) \u2192 complete_job.
1786
1847
  Skip ONLY for single-line typo fixes.
1787
1848
 
1849
+ ### Releasing Locks (CRITICAL)
1850
+ Every file you lock MUST be unlocked before your session ends. Dangling locks block all other agents.
1851
+ - complete_job releases locks for that job. Call it IMMEDIATELY after each task.
1852
+ - finalize_session clears ALL remaining locks. Call it before you stop responding.
1853
+ - NEVER end a session while holding locks. Self-check: "Did I call finalize_session?"
1854
+
1788
1855
  ### Session Cleanup (MANDATORY)
1789
- - complete_job IMMEDIATELY after finishing each task.
1856
+ - complete_job IMMEDIATELY after finishing each task \u2014 this is how locks get released.
1790
1857
  - update_shared_context after meaningful steps.
1791
- - finalize_session when the user's request is fully complete \u2014 do not wait to be told.
1858
+ - finalize_session when the user's request is fully complete \u2014 do not wait to be told. This clears all remaining locks.
1792
1859
 
1793
1860
  ### Force-Unlock Policy
1794
1861
  force_unlock is a LAST RESORT \u2014 only for locks >25 min old from a crashed agent. Always give a reason.
@@ -1945,7 +2012,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1945
2012
  // --- Decision & Orchestration ---
1946
2013
  {
1947
2014
  name: "propose_file_access",
1948
- description: "**CRITICAL: REQUEST FILE LOCK** \u2014 call this before EVERY file edit, no exceptions.\n- Checks if another agent currently holds a lock.\n- Returns `GRANTED` if safe to proceed, or `REQUIRES_ORCHESTRATION` if someone else is editing.\n- Usage: Provide your `agentId` (e.g., 'cursor-agent'), `filePath` (absolute), and `intent` (descriptive \u2014 e.g. 'Refactor auth to use JWT', NOT 'editing file').\n- Locks expire after 30 minutes. Use `force_unlock` only as a last resort for crashed agents.",
2015
+ description: "**CRITICAL: REQUEST FILE LOCK** \u2014 call this before EVERY file edit, no exceptions.\n- Checks if another agent currently holds a lock.\n- Returns `GRANTED` if safe to proceed, or `REQUIRES_ORCHESTRATION` if someone else is editing.\n- **Hierarchical matching**: Locking a directory also blocks locks on files within it, and vice versa. E.g. locking `src/api/` blocks `src/api/auth/login.ts`.\n- Usage: Provide your `agentId` (e.g., 'cursor-agent'), `filePath` (absolute), and `intent` (descriptive \u2014 e.g. 'Refactor auth to use JWT', NOT 'editing file').\n- Locks expire after 30 minutes. Use `force_unlock` only as a last resort for crashed agents.\n- **IMPORTANT**: Every lock you acquire MUST be released. Call `complete_job` when done with each task, and `finalize_session` before ending your session. Dangling locks block all other agents.",
1949
2016
  inputSchema: {
1950
2017
  type: "object",
1951
2018
  properties: {
@@ -1972,7 +2039,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
1972
2039
  // --- Permanent Memory ---
1973
2040
  {
1974
2041
  name: "finalize_session",
1975
- description: "**MANDATORY SESSION CLEANUP** \u2014 call this automatically when the user's request is fully complete.\n- Archives the current Live Notepad to a permanent session log.\n- Clears all active locks and completed jobs.\n- Resets the Live Notepad for the next session.\n- Do NOT wait for the user to say 'we are done.' When all tasks are finished, call this yourself.",
2042
+ description: "**MANDATORY SESSION CLEANUP** \u2014 call this automatically when the user's request is fully complete.\n- Archives the current Live Notepad to a permanent session log.\n- **Clears ALL active file locks** and completed jobs. This is your safety net to ensure no dangling locks.\n- Resets the Live Notepad for the next session.\n- Do NOT wait for the user to say 'we are done.' When all tasks are finished, call this yourself.\n- **CRITICAL**: You MUST call this before ending ANY session. Failing to do so leaves file locks that block all other agents.",
1976
2043
  inputSchema: { type: "object", properties: {}, required: [] }
1977
2044
  },
1978
2045
  {
@@ -2032,7 +2099,7 @@ server.setRequestHandler(ListToolsRequestSchema, async () => {
2032
2099
  },
2033
2100
  {
2034
2101
  name: "complete_job",
2035
- description: "**CLOSE TICKET**: Mark a job as done and release file locks.\n- Call this IMMEDIATELY after finishing each job \u2014 do not accumulate completed-but-unclosed jobs.\n- Requires `outcome` (what was done).\n- If you are not the assigned agent, you must provide the `completionKey`.\n- Leaving jobs open holds locks and blocks other agents.",
2102
+ description: "**CLOSE TICKET**: Mark a job as done and release file locks.\n- Call this IMMEDIATELY after finishing each job \u2014 do not accumulate completed-but-unclosed jobs.\n- Requires `outcome` (what was done).\n- If you are not the assigned agent, you must provide the `completionKey`.\n- **This is the primary way to release file locks.** Leaving jobs open holds locks and blocks other agents.\n- REMINDER: After completing all jobs, you MUST also call `finalize_session` to clear any remaining locks.",
2036
2103
  inputSchema: {
2037
2104
  type: "object",
2038
2105
  properties: {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@virsanghavi/axis-server",
3
- "version": "1.7.1",
3
+ "version": "1.8.0",
4
4
  "description": "Axis MCP Server CLI",
5
5
  "main": "dist/index.js",
6
6
  "bin": {